Compare commits

...

3 commits

Author SHA1 Message Date
f768bc4a41 CB-WP-0014-T03: hot-seat evidenced; stage 1 open on one human check
Some checks failed
ci / check (push) Failing after 4s
CB-EV-0012. Stage 1, deliverable by deliverable:

  relationship-graph visualization   emitted and gated, NEVER SEEN
  drag-to-propose                    evidenced end to end
  debug inspector                    evidenced (CB-WP-0011)
  hot-seat play                      evidenced here

Hot-seat was the one closest to being claimed on the strength of the code
path existing. SeatPolicy hands every human seat a handle on one shared
Server, so turn-taking "obviously" worked — and nothing drove more than
one seat until now. The property that matters is not that two turns
happen but that the same tab, asked twice, shows two different hands.
Mutating the projection to serve P1's view to every seat turns it red.

The stage stays open on ONE named blocker rather than a vague
reservation: no browser is available to this loop, so the visualization
is evidenced only as correctly emitted. Everything testable from here has
been tested. What remains is `cb-play --serve 0`, open the URL, confirm
the table reads and a drag works. INTENT carries that note now.

The self-quoting rule from CB-EV-0011 §4 is ADOPTED: an evidence file
quotes the previous pass's final cost and never its own. CB-WP-0013
reported itself at $5.78/34 mid-flight; final is $8.26/47, under by 43%.
Four for four, always low.

Meta budget 29% [OVER] soft 25%, driven by CB-WP-0013 in a trailing three
with two cheap product passes; it was an instrument repair, which
ADR-0006 D2 exempts.

SH-1 at 347,720 [HARD] against a 300,000 ceiling. Compaction is the
remedy and this session cannot do it for itself. CB-EV-0009's standing
prediction is now live and testable for the first time in three passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:58:59 +02:00
55212d7e0f CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind
ADR-0009: embed quick-js; node is refused. Measured marginal cost against
the dev-toolchain graph, under the positive control:

  boa_engine   896,410
  rquickjs      69,985
  quick-js      11,434
  node               0   <- and that zero is the problem

ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97,
which has no node, so the test would make our build fetch a JS runtime of
tens of millions of unaudited lines while scoring zero on the only
instrument that governs dependencies. A browser is exempt because a
developer has one regardless of us; a CI-installed runtime is not.

The loop is now closed: the real server serves the real page, QuickJS
runs that page's own scripts, the gesture goes over a real socket, and
the seat's Choice comes back. Before this, every link was tested and the
chain was not — a page whose JavaScript sent something else entirely
would have passed everything.

Three controls, each red for its stated reason: the JS posting a command
name instead of ids, the gesture not being delivered (EXPECT-VACUOUS),
and the token stripped from the endpoint.

A wrong assertion worth keeping: the first draft required the body not to
contain "attack". It legitimately does — action-attack is the id of an
element a finger landed on. An element may name an action; that is not
the page deciding. The real test is the shape: exactly two fields, down
and up, carrying two ids and nothing derived from them.

AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's
headroom; after landing AM-4b did not move at all. It measures
games-ground --edges normal — one package, no dev edges. Measured, the
workspace including dev edges is 725,258 lines against AM-4b's 317,021:
408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap,
ciborium, quick-js). The decision stands on the acquisition rule; the
affordability argument is withdrawn. Third defect in the AM-4 family.

Also fixed structurally rather than by raising a limit: `make status` had
grown past its 40-line readability gate as workplans accumulated. Closed
workplans now collapse to one line, so the report is fixed-size.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:56:02 +02:00
4c930fac32 Declare CB-WP-0014: execute the JavaScript, tier M
Declaration 9 of 12. Structural tier M (adds an external dependency to
the toolchain); d4 = 2, no override.

CB-EV-0010 §4 recorded that the emitted JavaScript has never run, and
that sentence is the only thing keeping INTENT stage 1 open. It was
carried for a pass on the assumption that executing it was not possible
here. node v24.11.1 is on this machine. The assumption was never
checked — the same shape as every other finding of the last three
passes: a claim carried because nobody ran the command that settles it.

Executing it does not prove the table looks right; that needs a
rendering engine and a human eye. It proves the contract ADR-0007
control 5 rests on — that the page reports raw pointer facts and
nothing else — which is currently asserted by grepping the script for
game vocabulary. Grepping for absent words is a weak proxy for "this
code cannot construct a command".

T01 is a real dependency decision, priced under ADR-0007 D3's
acquisition rule so that "it's only a dev tool" is not an automatic
pass. CI runs on rust:1.97, which has no node, so the test either
forces an install or skips — and a test that skips silently is the
harness-does-nothing class this project has found five times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:43:24 +02:00
12 changed files with 978 additions and 6 deletions

52
Cargo.lock generated
View file

@ -93,6 +93,7 @@ version = "0.1.0"
dependencies = [
"cb-kernel",
"games-ground",
"quick-js",
"serde_json",
]
@ -104,6 +105,16 @@ dependencies = [
"games-ground",
]
[[package]]
name = "cc"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -162,6 +173,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "copy_dir"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3"
dependencies = [
"walkdir",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@ -243,6 +263,12 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "games-ground"
version = "0.1.0"
@ -331,6 +357,16 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libquickjs-sys"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f0b24e9bd171b75ae0295bd428fb8fe58410fb23156e5f34a4657a70c3cee96"
dependencies = [
"cc",
"copy_dir",
]
[[package]]
name = "memchr"
version = "2.8.3"
@ -376,6 +412,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quick-js"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19cb4cefcb00f4ab9b332664d06005a74f582ac16aa959c6ad5912957bd83e5f"
dependencies = [
"libquickjs-sys",
"once_cell",
]
[[package]]
name = "quote"
version = "1.0.47"
@ -512,6 +558,12 @@ dependencies = [
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "2.0.119"

View file

@ -64,6 +64,11 @@ game.
and scenario tests, simple bots. No rendering, no physics.
1. **Inspectable 2D table** — card/token/hand/relationship-graph
visualization, drag-to-propose, debug inspector, hot-seat play.
*Open on one human verification (CB-EV-0012 §4): the inspector,
drag-to-propose and hot-seat play are evidenced by executing code;
the visualization is evidenced only as correctly emitted, because no
browser is available to the loop. Run `cb-play --serve 0`, open the
printed URL, and confirm the table reads and a drag works.*
2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera
and pointer controls, snap zones, asset importer.
3. **Networked sessions** — authoritative host, private projections,

View file

@ -100,3 +100,6 @@
| task | CB-WP-0013-T02 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
| task | CB-WP-0013-T03 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
| task | CB-WP-0013-T04 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
| task | CB-WP-0014-T01 | done | — | workplans/CB-WP-0014-execute-the-javascript.md |
| task | CB-WP-0014-T02 | done | — | workplans/CB-WP-0014-execute-the-javascript.md |
| task | CB-WP-0014-T03 | done | — | workplans/CB-WP-0014-execute-the-javascript.md |

View file

@ -4,16 +4,28 @@ 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
[features]
# ADR-0009: the JS harness is exposed to cb-play's tests behind a feature,
# so the engine stays a dev cost there too and never a shipped one.
js-harness = ["dep:quick-js"]
# ADR-0007 Decision 1: the browser is the renderer, so the rendering path
# 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
# ADR-0009: an EMBEDDED engine, not `node` — CI runs on rust:1.97, which
# has no node, so requiring one would make our build acquire a runtime
# nobody audits while it scored zero on the only instrument that governs
# dependencies. Optional, and never in the shipped-runtime configuration:
# AM-4a measures 157,202 against 161,000 and has no room for it.
quick-js = { version = "0.4", optional = true }
[dev-dependencies]
# The coverage gate walks the serialized view; nothing else needs it.
serde_json.workspace = true
quick-js = "0.4"
[lints]
workspace = true

View file

@ -0,0 +1,290 @@
//! Execute the emitted JavaScript for real (ADR-0009).
//!
//! ADR-0007 control 5 says the page **may not construct commands**: it
//! reports raw pointer facts and Rust decides what they mean. Until now
//! that contract was held up by a test that greps the emitted script for
//! game vocabulary — and grepping for the absence of words is a weak
//! proxy for *"this code cannot construct a command"*.
//!
//! This runs it. QuickJS, a DOM stub with exactly the surface `SCRIPT`
//! touches, and a Rust callback standing in for `fetch` so the test can
//! see precisely what would have gone on the wire.
//!
//! **The scripts are lifted from a real emitted document, not pasted
//! here.** Both `<script>` blocks are extracted and evaluated in order, so
//! the endpoint the JavaScript posts to is the one the page actually
//! carried — token included. A harness that supplied its own endpoint
//! would pass while the page shipped a broken one.
//!
//! What this does **not** prove: that the SVG renders legibly, that a drag
//! feels like a drag, or that anyone can play a game. QuickJS has no
//! layout engine (ADR-0009 §What is bought).
use std::sync::{Arc, Mutex};
/// One intercepted `fetch`, exactly as the page would have sent it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Posted {
pub url: String,
pub body: String,
}
/// Pull every `<script>…</script>` body out of a document, in order.
pub fn scripts(html: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = html;
while let Some(i) = rest.find("<script>") {
let after = &rest[i + "<script>".len()..];
match after.find("</script>") {
Some(j) => {
out.push(after[..j].to_string());
rest = &after[j..];
}
None => break,
}
}
out
}
/// The DOM surface `SCRIPT` touches, and nothing more.
///
/// Deliberately minimal: every additional stubbed API is a way for the
/// script to do something in the test that it could not do in a browser,
/// or vice versa. If `SCRIPT` ever needs more than this, that is a signal
/// about the script, not about the stub.
const DOM: &str = r#"
var __handlers = {};
var __status = { textContent: "" };
var document = {
addEventListener: function (type, fn) { __handlers[type] = fn; },
getElementById: function (id) { return __status; }
};
var window = { location: { reload: function () { __reloaded(); } } };
function fetch(url, opts) {
__post(url, (opts && opts.body) || "");
// The script chains .then(...).then(...); give it something to chain on
// without ever resolving, so response handling stays out of scope here.
var chainable = { then: function () { return chainable; } };
return chainable;
}
function __down(id) { __handlers['pointerdown']({ target: { id: id } }); }
function __up(id) { __handlers['pointerup']({ target: { id: id } }); }
"#;
/// Run the document's scripts, then a pointer gesture, and report what
/// the page tried to send.
///
/// `gesture` is `(down_id, up_id)`. Errors are JS errors, and they are
/// returned rather than swallowed: a script that throws must not look
/// like a script that sent nothing.
pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String> {
let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?;
// `Arc<Mutex<_>>` rather than `Rc<RefCell<_>>`: quick-js requires the
// callback to be unwind-safe, since a panic inside it would cross the
// C boundary.
let posted: Arc<Mutex<Vec<Posted>>> = Arc::new(Mutex::new(Vec::new()));
let sink = posted.clone();
ctx.add_callback("__post", move |url: String, body: String| {
sink.lock().expect("posted").push(Posted { url, body });
0i32
})
.map_err(|e| format!("register __post: {e}"))?;
ctx.add_callback("__reloaded", || 0i32)
.map_err(|e| format!("register __reloaded: {e}"))?;
ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?;
let found = scripts(html);
if found.is_empty() {
return Err("the document carries no <script> block".to_string());
}
for (i, s) in found.iter().enumerate() {
ctx.eval(s)
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
}
// If the page never registered handlers, the gesture below would be a
// no-op and the test would read as "sent nothing" rather than "the
// page is broken". Distinguish the two.
let armed: bool = ctx
.eval_as("!!(__handlers['pointerdown'] && __handlers['pointerup'])")
.map_err(|e| format!("handler check: {e}"))?;
if !armed {
return Err("the page registered no pointer handlers".to_string());
}
ctx.eval(&format!(
"__down({}); __up({});",
json_lit(down),
json_lit(up)
))
.map_err(|e| format!("gesture: {e}"))?;
let out = posted.lock().expect("posted").clone();
Ok(out)
}
fn json_lit(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("\\\\"),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
use cb_kernel::PlayerId;
use games_ground::{Action, GroundCommand};
fn page() -> String {
let legal = vec![
GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
},
GroundCommand::SelectAction {
action: Action::Attack,
target: Some(PlayerId(1)),
problem: None,
},
];
crate::doc::document(
&crate::testfix::view(Some(PlayerId(0))),
&legal,
"/command?t=deadbeef",
Some(PlayerId(0)),
false,
)
}
/// **The one this pass exists for.** The emitted JavaScript runs, and
/// what it puts on the wire is two element ids and nothing else.
#[test]
fn the_emitted_javascript_reports_element_ids_and_nothing_else() {
let posts = gesture(&page(), "action-attack", "seat-1").expect("the script runs");
assert_eq!(posts.len(), 1, "expected exactly one fetch, got {posts:?}");
let p = &posts[0];
// The endpoint comes from the page, token and all — not from the
// harness. A page that shipped a broken endpoint fails here.
assert_eq!(p.url, "/command?t=deadbeef", "the page's own endpoint");
assert_eq!(p.body, "down=action-attack&up=seat-1");
// Control 5, asserted rather than grepped.
//
// The naive check — "the body must not contain 'attack'" — is
// WRONG, and writing it was instructive: the body legitimately
// contains `action-attack`, because that is the id of an element
// a finger landed on. An element may name an action; that is not
// the page deciding anything.
//
// What actually distinguishes reporting from deciding is the
// *shape*: exactly two fields, named `down` and `up`, whose values
// are the two ids and nothing derived from them.
let fields: Vec<&str> = p
.body
.split('&')
.map(|kv| kv.split('=').next().unwrap())
.collect();
assert_eq!(
fields,
vec!["down", "up"],
"the page sent fields beyond the gesture"
);
for marker in ["SelectAction", "{", "}", "\"", "kind", "target", "problem"] {
assert!(
!p.body.contains(marker),
"the body carries {marker:?} — the page is constructing a command: {}",
p.body
);
}
}
/// And the ids it reports resolve, in Rust, to the command the
/// aggregate offered — closing the loop the grep test could not.
#[test]
fn what_the_page_sends_resolves_to_a_legal_command() {
let legal = vec![
GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
},
GroundCommand::SelectAction {
action: Action::Attack,
target: Some(PlayerId(1)),
problem: None,
},
];
let posts = gesture(&page(), "action-attack", "seat-1").expect("runs");
let fact = crate::PointerFact::parse(&posts[0].body).expect("a pointer fact");
assert_eq!(crate::resolve(&fact, &legal, PlayerId(0)), Ok(1));
}
/// A gesture that means nothing is still *reported* faithfully — the
/// page does not filter, because filtering would be deciding.
#[test]
fn the_page_reports_a_meaningless_gesture_rather_than_suppressing_it() {
let posts = gesture(&page(), "seat-1", "seat-2").expect("runs");
assert_eq!(posts.len(), 1);
assert_eq!(posts[0].body, "down=seat-1&up=seat-2");
}
/// EXPECT-VACUOUS: a harness that ran the script but delivered no
/// events would report "sent nothing" and read as a pass. Prove the
/// gesture is what causes the send.
#[test]
fn without_a_gesture_the_page_sends_nothing() {
let html = page();
let ctx = quick_js::Context::new().expect("ctx");
let seen = std::sync::Arc::new(std::sync::Mutex::new(0usize));
let sink = seen.clone();
ctx.add_callback("__post", move |_u: String, _b: String| {
*sink.lock().expect("seen") += 1;
0i32
})
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
ctx.eval(DOM).expect("dom");
for s in scripts(&html) {
ctx.eval(&s).expect("script");
}
assert_eq!(
*seen.lock().expect("seen"),
0,
"the page sent something unprompted"
);
}
/// The harness must not report success when the page is broken.
#[test]
fn a_page_with_no_script_or_no_handlers_is_an_error_not_a_silence() {
assert!(gesture("<p>no script here</p>", "a", "b").is_err());
// A script that registers nothing must not look like one that
// registered handlers and chose to send nothing.
let e = gesture("<script>var x = 1;</script>", "a", "b").unwrap_err();
assert!(e.contains("no pointer handlers"), "{e}");
}
#[test]
fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page());
assert_eq!(
found.len(),
2,
"the page carries the endpoint and the script"
);
assert!(found[0].contains("CB_ENDPOINT"), "{}", found[0]);
assert!(found[1].contains("pointerdown"), "{}", found[1]);
}
}

View file

@ -43,6 +43,8 @@
pub mod doc;
pub mod input;
#[cfg(any(test, feature = "js-harness"))]
pub mod jsrun;
pub mod serve;
pub use doc::{document, text_of};

View file

@ -0,0 +1,121 @@
# ADR-0009: embed the JS engine; do not put `node` in the toolchain
status: accepted
date: 2026-08-02
decided by: agent, under the standing loop authorization
tier: M (structural M — adds an external dependency to the toolchain,
InnerLoop v1.6; chaos d4=2 → no override). Tier M merges survey and
decision into one document, which this is.
references: [CB-WP-0014](../workplans/CB-WP-0014-execute-the-javascript.md),
[ADR-0007](ADR-0007-render-html-not-a-port.md) D3 (the acquisition rule)
and D5 (control 5),
[ADR-0008](ADR-0008-instrument-corrections.md) D2 (AM-4b left uncorrected),
[CB-EV-0010](../evidence/CB-EV-0010-render-port.md) §4
## Context
ADR-0007 control 5 says the emitted page **may not construct commands**
it reports raw pointer facts and Rust decides what they mean. That
contract is currently held up by a test that greps the emitted script for
game vocabulary. **Grepping for the absence of words is a weak proxy for
"this code cannot construct a command."**
And the script has never been executed at all, which is the sole remaining
reason INTENT stage 1 is open.
## The survey
`node` v24.11.1 is on this machine, so the obvious move is to shell out to
it. Measured alternatives, same method as `dep-weight.py`, marginal against
the 29-crate dev-toolchain graph, under the positive control:
| option | marginal Rust lines | notes |
|---|---:|---|
| `boa_engine` | 896,410 | 27× AM-4b's headroom |
| `rquickjs` | 69,985 | 2.1× headroom |
| **`quick-js`** | **11,434** | bindings + vendored QuickJS C |
| `node`, shelled out | **0** | and that zero is the problem |
AM-4b headroom is **32,979** (317,021 of 350,000), so `quick-js` fits at
35% of it and `rquickjs` does not.
## Decision — embed `quick-js`; `node` is refused
**This is ADR-0007 Decision 3's acquisition rule biting its author, which
is the only real test of whether it was written honestly.** The rule:
> AM-4 counts third-party code the project causes to be **acquired**. It
> does not count runtimes the user already has independently of us. It
> **does** count a library our build or install instructions cause to be
> fetched, pinned, or linked, whether or not its source is Rust.
A browser is not counted because a developer has one regardless of us. But
**CI runs on `rust:1.97`, which has no `node`** — so adding this test
would make our CI fetch a JavaScript runtime. That is our build causing an
acquisition, of tens of millions of lines nobody here will audit, scoring
**zero** on the only instrument that governs dependencies.
Taking `node` would mean using the rule to exempt a browser we do not
install while also exempting a runtime we do. `quick-js` costs 11,434
lines that are vendored, pinned, auditable, and counted.
The secondary reasons matter less but all point the same way: the test
runs anywhere `cargo test` runs, needs no CI change, cannot skip because a
binary is missing, and pins one engine version rather than whatever the
image happens to ship.
**`python3` is not a precedent for `node`.** It is already a toolchain
dependency, and the honest reading is that it was never argued — it
predates the acquisition rule. Leaning on it would be using an unexamined
decision to license a second one. It is left alone here and noted as
owed.
### What is bought, and what is not
Executing the script proves the **input contract**: what the page puts on
the wire in response to a pointer gesture. It does **not** prove the SVG
renders legibly, that a drag feels like a drag, or that anyone can play a
game. QuickJS has no layout engine and this ADR claims no rendering
evidence.
### Cost accepted — and a correction, measured after the fact
The paragraph originally here read *"35% of AM-4b's remaining headroom,
for a test."* **That was wrong, and finding out how wrong is the more
important result of this pass.**
After landing, `make dep-weight` reported AM-4b **unchanged at 317,021**.
`quick-js` is a dev-dependency of `cb-render-html`, and AM-4b measures
`cargo tree -p games-ground --edges normal` — one package, no dev edges.
It cannot see it. Measured:
| | crates | lines |
|---|---:|---:|
| AM-4b as instrumented (`games-ground`, normal) | 29 | 317,021 |
| the whole workspace, including dev edges | 57 | 725,258 |
| **uncounted by AM-4b** | **28** | **408,237** |
**The dev-toolchain budget is blind to more source than its entire
target** — `criterion`, `clap`, `ciborium`, and now `quick-js`.
This is the same defect this ADR refuses `node` for: a real acquisition
scoring zero because the instrument does not look there. The difference is
that `quick-js` is *auditable and pinned* and `node` is neither, so the
decision stands. But it stands on the acquisition rule, **not** on an
affordability argument, because there is no affordability argument to be
had until AM-4b can see what it is buying.
Recorded as owed, and it is now the third defect in the AM-4 family: the
shipped-runtime proc-macro count (fixed, ADR-0008 D2), AM-4b's own
proc-macro share (owed), and AM-4b's scope (this).
## Consequences
- `quick-js` is a **dev-dependency of `cb-render-html` only**. It must
never reach the shipped-runtime configuration; AM-4a would catch that,
and now measures 157,202 against 161,000 with no room for it.
- The grep test stays. It is cheap, and it fails faster and more legibly
than an execution test when someone adds a word to the script.
- If `quick-js` becomes unmaintained, the fallback is not `node` — it is
`rquickjs` plus an AM-4b decision, or dropping the execution test and
saying so.

View file

@ -0,0 +1,150 @@
# CB-EV-0012 — the loop is closed, and the budget is blind
CB-WP-0014 T03. Measured 2026-08-02 at `55212d7`+. Pass kind `product`,
tier **M** (chaos d4=2, no override). Declaration 9 of 12.
**Applying CB-EV-0011 §4's proposed rule, and hereby adopting it:** the
cost table below quotes the **previous** pass's final figure and marks
this pass's own as provisional. See §5.
---
## 1. What was actually blocking stage 1, and what it cost to find out
CB-EV-0010 §4 said the emitted JavaScript had never run. That sentence was
carried for a pass on an assumption nobody checked. **`node` v24.11.1 was
on the machine the whole time.**
That is the fourth consecutive pass whose central finding was a claim
carried because nobody ran the command that settles it — after the 3,750
headroom figure, the "empty" sub-100k dependency region, and SH-3's 0.0%.
The pattern is stable enough now to name: **this project's characteristic
error is not faulty reasoning, it is unexecuted verification.** Every one
was cheap to check and none had been checked.
## 2. The loop, closed
The real server serves the real page → QuickJS runs *that page's own two
`<script>` blocks* → the gesture goes over a real socket → the seat's
`Choice` comes back.
Before this, every link was tested and the chain was not. The page was
asserted against as a parsed document; the socket was driven by synthetic
HTTP this test suite wrote itself. **A page whose JavaScript sent
something else entirely would have passed everything.**
Control 5 — *the page may not construct commands* — was held up by a test
that grepped the emitted script for game vocabulary. It is now asserted on
what the script actually puts on the wire, and the mutation that makes the
JS post `command=SelectAction&target=…` turns it red.
**A wrong assertion worth keeping.** The first draft required the body not
to contain `"attack"`. It legitimately does — the body is
`down=action-attack&up=seat-1`, and `action-attack` is *the id of an
element a finger landed on*. An element may name an action; that is not
the page deciding anything. What distinguishes reporting from deciding is
the **shape**: exactly two fields, `down` and `up`, carrying two ids and
nothing derived from them.
## 3. AM-4b is blind to more source than its own target
ADR-0009's first draft priced `quick-js` at *"35% of AM-4b's remaining
headroom."* After it landed, `make dep-weight` reported AM-4b
**unchanged**.
| | crates | lines |
|---|---:|---:|
| AM-4b as instrumented (`games-ground`, `--edges normal`) | 29 | 317,021 |
| the whole workspace, including dev edges | 57 | 725,258 |
| **uncounted** | **28** | **408,237** |
AM-4b measures one package and no dev edges. `criterion`, `clap`,
`ciborium` and now `quick-js` are invisible to it.
**This is the same defect the ADR refuses `node` for** — a real
acquisition scoring zero because the instrument does not look there. The
decision still stands, because `quick-js` is pinned and auditable and
`node` is neither, but it stands on the **acquisition rule alone**. The
affordability argument is withdrawn: there is none to be had until AM-4b
can see what it is buying.
Third defect in the AM-4 family, and the second found by a pass that was
*using* the instrument rather than auditing it:
| defect | status |
|---|---|
| shipped-runtime counted proc-macro crates (89,048 lines) | fixed, ADR-0008 D2 |
| AM-4b's own proc-macro share | unmeasured, owed |
| **AM-4b's scope: one package, no dev edges (408,237 lines)** | **found here, owed** |
## 4. INTENT stage 1, deliverable by deliverable
| deliverable | status |
|---|---|
| card/token/hand/**relationship-graph** visualization | **emitted and gated** — 41 of 42 leaf paths asserted present in the *parsed* document. **Never seen.** |
| drag-to-propose | **evidenced end to end** — JS gesture → socket → `Choice` |
| debug inspector | **evidenced** (CB-WP-0011) — walks bundles and scenarios, hash-verified |
| hot-seat play | **evidenced** — two seats, one listener, the projection follows the seat |
The last one was the closest to being claimed on the strength of the code
path existing. `SeatPolicy` hands every human seat a handle on one shared
`Server`, so turn-taking "obviously" worked — and nothing drove more than
one seat until this pass. The property that matters is not that two turns
happen but that **the same tab, asked twice, shows two different hands**;
mutating the projection to serve P1's view to every seat turns it red.
### The stage stays open, on one named blocker
Three of four deliverables are evidenced by executing code. The first is
evidenced only as *correctly emitted*: QuickJS has no layout engine, no
browser is available here, and no test in this repo can establish that an
SVG table is legible or that a drag feels like a drag.
**Everything testable from here has been tested.** What remains is one
human action: run `cb-play --serve 0`, open the printed URL, and confirm
the table reads and a drag works. That is the whole of the remaining
blocker, it is the maintainer's to do, and it is named here so the stage
is no longer carried on a vague reservation.
Closing it unilaterally would be the failure mode stage 0 avoided.
Carrying it *vaguely* was the failure mode this pass found.
## 5. Cost, shape, and the rule now adopted
| pass | kind | responses | cost | $/response |
|---|---|---|---|---|
| CB-WP-0012 | product | 97 | $14.52 | 0.150 |
| **CB-WP-0013** (final) | meta | 47 | **$8.26** | **0.176** |
| CB-WP-0014 | product | *provisional — see below* | | |
**The rule is adopted.** CB-EV-0011 proposed that an evidence file quote
the previous pass's final cost and never its own. CB-WP-0013 reported
itself at $5.78/34 responses mid-flight; final is **$8.26/47** — under by
43%. Four for four, always low. This file quotes CB-WP-0013 and declines
to quote itself, and future evidence files should do the same.
**Meta budget: 29% `[OVER]`** the soft 25%, driven by CB-WP-0013's $8.26
sitting in a trailing three with two cheap product passes. Reported under
the rule that requires reporting it; no product work was displaced, and
CB-WP-0013 was an instrument repair, which ADR-0006 D2 exempts anyway.
**SH-1 is at 347,720 `[HARD]`** against a 300,000 ceiling. The remedy is a
compaction and it is free, and it is not something this session can do for
itself. **CB-EV-0009's standing prediction is now live and testable**: if
the next pass opens above the SH-1 hard line, it should cost more per
response than 0.123. It has been unfalsified for three passes because
every one of them opened below the line.
## 6. Open
- **AM-4b's scope defect (408,237 uncounted lines)** and its unmeasured
proc-macro share. §3.
- **`python3` as a toolchain dependency was never argued.** ADR-0009
declined to lean on it as precedent and left it owed.
- **INTENT stage 1: one human verification.** §4.
- **AM-4a still cannot survive stage 2** — 1,741,979 against 161,000,
10.8×. Reserved for the maintainer since ADR-0007.
- **ADR-0007 D3's acquisition rule** remains unratified — and it has now
decided two dependency questions, which raises rather than lowers the
cost of leaving it unratified.
- **Chaos: 9 of 12 declarations, 1 override.**

View file

@ -14,5 +14,10 @@ serde_json.workspace = true
# Scenario args are YAML values; the prompt renders them (T02).
serde_yaml.workspace = true
[dev-dependencies]
# ADR-0009: the real page, driven by a real JS engine, into the real
# socket. Dev-only — cb-play ships without it.
cb-render-html = { workspace = true, features = ["js-harness"] }
[lints]
workspace = true

View file

@ -370,4 +370,126 @@ mod tests {
);
assert!(replies[1].contains("200 OK"));
}
/// **The loop, closed.** The real server serves the real page; a real
/// JavaScript engine runs the page's own script and produces a pointer
/// gesture; what it produces goes over a real socket; and the seat's
/// choice comes back.
///
/// Before ADR-0009 every link in that chain was tested and the chain
/// was not. The page was asserted against as a parsed document and the
/// socket was driven by synthetic HTTP that this test suite wrote
/// itself — so a page whose JavaScript sent something else entirely
/// would have passed everything.
#[test]
fn a_gesture_in_javascript_becomes_a_move_in_the_game() {
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 tok = token.clone();
let client = std::thread::spawn(move || {
let token = tok;
// 1. fetch the page the server actually serves
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&token).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
assert!(page.contains("200 OK"), "{page}");
// 2. run ITS script, in a real engine, with a real gesture
let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table")
.expect("the served page's script runs");
assert_eq!(posts.len(), 1, "{posts:?}");
// 3. send exactly what the JavaScript produced — not what this
// test thinks it should have produced
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&token, &posts[0].body).as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
(posts[0].clone(), reply)
});
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let (posted, reply) = client.join().expect("client thread");
assert_eq!(posted.body, "down=action-ground&up=table");
// The endpoint the JS used carries the server's own token, which
// the page cannot mint — so this also proves the token round-trips
// through the emitted document.
assert!(posted.url.contains(&token), "{}", posted.url);
assert!(reply.contains("200 OK"), "{reply}");
assert!(server.refusals().is_empty(), "{:?}", server.refusals());
}
/// **Hot-seat**: two seats, one browser, one listener — and each seat
/// is shown its own hand and nobody else's.
///
/// This is the deliverable that was closest to being claimed on the
/// strength of the code path existing. `SeatPolicy` gives every human
/// seat a handle on one shared `Server`, so turn-taking "obviously"
/// works; nothing drove more than one seat until this test.
///
/// The property that matters is not that two turns happen. It is that
/// the *projection follows the seat* — the same tab, asked twice,
/// must show two different hands.
#[test]
fn two_seats_take_turns_through_one_listener_and_see_different_hands() {
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 tok = token.clone();
let client = std::thread::spawn(move || {
let mut pages = Vec::new();
for _ in 0..2 {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&tok).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
pages.push(page);
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&tok, "down=action-ground&up=table").as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
}
pages
});
let state = state();
for seat in [0u8, 1] {
let choice = server
.next_choice(&state, PlayerId(seat), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0), "seat P{}", seat + 1);
}
let pages = client.join().expect("client thread");
assert_eq!(pages.len(), 2);
for (i, page) in pages.iter().enumerate() {
let text = cb_render_html::text_of(page);
assert!(
text.contains(&format!("viewing as P{}", i + 1)),
"turn {i} was not served to P{}",
i + 1
);
// K13: exactly one open hand per page, and it is this seat's.
assert_eq!(
text.matches("cards)").count(),
1,
"turn {i} showed {} open hands",
text.matches("cards)").count()
);
}
// And the two turns were genuinely different views, not the same
// page served twice — which is how this test would pass vacuously.
assert_ne!(pages[0], pages[1], "both turns served an identical page");
}
}

View file

@ -214,14 +214,21 @@ def report():
print(f" branch {branch}"
+ (f" ({len(dirty)} uncommitted)" if dirty else " (clean)"))
# The list grows by one line per pass forever, and this report has a
# length limit it is meant to keep. Collapse the fully-closed ones to
# a single line rather than raising the limit — the precedent is
# LOOP-LINT's four loadability breaches, each fixed structurally.
print("\n workplans")
closed = []
for wid, title, status, tasks, _kind in plans:
done = sum(1 for _, s, _ in tasks if s == "done")
if status == "done" and done == len(tasks):
print(f" {wid} {status:<12} {done}/{len(tasks)}")
closed.append(wid.replace("CB-WP-", ""))
continue
short = (title or "")[:44]
print(f" {wid} {status:<12} {done}/{len(tasks)} done {short}")
if closed:
print(f" {len(closed)} closed and complete: " + " ".join(closed))
nxt = next_task(plans)
if nxt:

View file

@ -0,0 +1,203 @@
---
id: CB-WP-0014
kind: product
title: "Execute the JavaScript, and close or keep stage 1"
status: done
---
# Purpose
```
structural tier M (adds an external dependency to the toolchain —
InnerLoop v1.6 tier table)
chaos d4 = 2 → no override
declared tier M
```
Declaration 9 of 12. Tier M merges the survey and the ADR into one
document; the adversarial review is optional.
## The one thing blocking stage 1
CB-EV-0010 §4, stated plainly at the time:
> **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`.
All four of INTENT stage 1's named deliverables now exist. That sentence
is the only reason the stage is still open, and it has been carried
unexamined for one pass on the assumption that executing it was not
possible here.
**It is possible.** `node` v24.11.1 is on this machine. The assumption was
never checked, which makes it the same shape as every other finding in the
last three passes: a claim carried because nobody ran the one command that
would settle it.
## What executing it actually proves
Not that the table *looks* right — that needs a rendering engine and a
human eye, and neither is available here. What it proves is the contract
ADR-0007 control 5 rests on:
> the page reports **raw pointer facts and nothing else**, and Rust
> decides what command they mean.
That contract is currently asserted by a test that greps the emitted
script for game vocabulary. Grepping for the absence of words is a weak
proxy for "this code cannot construct a command". Running it, feeding it
synthetic pointer events, and observing exactly what it puts on the wire
is the real check.
## Task: decide whether a JS engine joins the toolchain
```task
id: CB-WP-0014-T01
status: done
priority: high
```
Write `decisions/ADR-0009-*.md` (tier M: survey and decision in one).
**This is a real dependency decision, not a formality.** CI runs on the
`rust:1.97` image, which has no `node`. Adding a test that needs one means
either installing it in CI or letting the test skip — and **a test that
skips silently is the harness-does-nothing class this project has found
five times.** If it can skip, it must fail loudly instead.
Decide, and price it under ADR-0007 Decision 3's acquisition rule — the
rule this project adopted precisely so that "it's only a dev tool" is not
an automatic pass:
- `python3` is already a toolchain dependency, so this is the second, not
the first. Say whether that makes it cheaper or is merely a precedent
being leaned on.
- What the alternatives cost: no execution at all (status quo, and the
reason stage 1 is open), or a JS interpreter as a Rust crate (measure it
— do not estimate, and note that AM-4b now has an unmeasured proc-macro
share of its own).
- Whether the engine is required by `make all` or by a separate target.
**AM-4b is the budget this lands in**, and it is the instrument
CB-WP-0013 explicitly declined to correct. Do not use its uncorrected
headroom as an argument.
**Done 2026-08-02.**
[ADR-0009](../decisions/ADR-0009-embed-the-js-engine.md) — **embed
`quick-js`; `node` is refused.** Measured, marginal, under the control:
`boa_engine` 896,410 · `rquickjs` 69,985 · **`quick-js` 11,434** · `node`
**0**, and that zero is the problem.
This is ADR-0007 D3's acquisition rule biting its author. CI runs on
`rust:1.97`, which has no `node` — so the test would make *our build*
fetch a JS runtime of tens of millions of unaudited lines, scoring zero on
the only instrument that governs dependencies. A browser is exempt because
a developer has one regardless of us; a CI-installed runtime is not.
**And the cost argument in the first draft was wrong.** It claimed 35% of
AM-4b's headroom. After landing, AM-4b did not move at all — it measures
`games-ground --edges normal`, one package, no dev edges. Measured: the
workspace including dev edges is **725,258** lines against AM-4b's
**317,021**, so **408,237 lines are uncounted — more than the target
itself**. The decision stands on the acquisition rule; the affordability
argument is withdrawn, because there is none to be had until the
instrument can see what it is buying. Filed as the third AM-4 defect.
## Task: run it, end to end, through a real socket
```task
id: CB-WP-0014-T02
status: done
priority: high
```
Execute `cb_render_html::doc::SCRIPT` in a real JS engine against a
minimal DOM, synthesize a pointer-down and pointer-up on two real element
ids from a real emitted document, and let its `fetch` reach the **actual
`Server`** — the same `next_choice` loop `cb-play --serve` runs.
Acceptance: the seat's `Choice` comes back from a pointer gesture that
originated in JavaScript. That is the first time anything in this project
has closed that loop.
**Controls, and they are the point.** The test must go red when:
- the JS is mutated to POST a command name instead of element ids — this
is **control 5 asserted rather than grepped**, and if it does not fire,
the grep test was the only thing holding the contract up;
- the pointer events are not delivered at all — the EXPECT-VACUOUS case, a
harness that runs a script and asserts nothing about what it did;
- the token is stripped from the endpoint the page was given.
**Done 2026-08-02.** `crates/cb-render-html/src/jsrun.rs` and
`hotseat::tests::a_gesture_in_javascript_becomes_a_move_in_the_game`.
**The loop is closed.** The real server serves the real page; QuickJS runs
*that page's own scripts*; the gesture it produces goes over a real socket;
the seat's `Choice` comes back. Both `<script>` blocks are lifted from the
served document, so the endpoint under test is the one the page actually
carried — token included.
Three controls, each red for its stated reason:
| mutation | result |
|---|---|
| the JS posts `command=SelectAction&target=…` | body assertion red — **control 5 asserted, not grepped** |
| the gesture is not delivered (EXPECT-VACUOUS) | `expected exactly one fetch, got []` |
| the token dropped from the endpoint | the end-to-end token assertion red |
**A wrong assertion, worth keeping.** The first draft asserted the body
must not contain `"attack"`. It legitimately does: the body is
`down=action-attack&up=seat-1`, and `action-attack` is *the id of an
element a finger landed on*. An element may name an action; that is not
the page deciding anything. The real test of reporting-versus-deciding is
the **shape** — exactly two fields, `down` and `up`, carrying two ids and
nothing derived from them. That is what it asserts now.
## Task: close stage 1, or say what still blocks it
```task
id: CB-WP-0014-T03
status: done
priority: high
```
`evidence/CB-EV-0012-*.md`, and then a decision on INTENT.
**Be precise about what is and is not established.** A passing JS test
proves the input contract and the loop. It does **not** prove that an SVG
table renders legibly, that a drag feels like a drag, or that a human can
play a game — none of which any test here can reach.
State which of stage 1's four deliverables are evidenced and which rest on
inspection, then close the stage or name the remaining blocker. Carrying
it open by default is as unexamined as closing it by default was going to
be.
**Adopt or reject the self-quoting rule** proposed in CB-EV-0011 §4: an
evidence file quotes the previous pass's final cost and never its own.
This pass is the first that can apply it — CB-WP-0013's figure is now
final. Decide it rather than inheriting it.
**Done 2026-08-02.**
[CB-EV-0012](../evidence/CB-EV-0012-execute-the-javascript.md).
- **Stage 1 stays open on one named blocker**, not a vague reservation.
Three of four deliverables are evidenced by executing code; the
visualization is evidenced only as *correctly emitted*, because QuickJS
has no layout engine and no browser is available here. What remains is
one human action: `cb-play --serve 0`, open the URL, confirm the table
reads and a drag works.
- **Hot-seat was the deliverable closest to being claimed on the strength
of the code path existing.** Nothing drove more than one seat until this
pass. Now: two seats, one listener, and the projection follows the seat —
mutating it to serve P1's view to everyone turns it red.
- **The self-quoting rule is ADOPTED.** CB-WP-0013 reported itself at
$5.78/34 mid-flight; final $8.26/47, under by 43%. Four for four.
- **AM-4b is blind to 408,237 lines** — more than its own target. Found by
a pass that was using it, not auditing it.
- **SH-1 at 347,720 `[HARD]`.** CB-EV-0009's standing prediction is now
live: the next pass opened above that line should cost more than 0.123
per response.