diff --git a/Cargo.lock b/Cargo.lock
index 86e13e2..305976a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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"
diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml
index 4435a33..204baaa 100644
--- a/crates/cb-render-html/Cargo.toml
+++ b/crates/cb-render-html/Cargo.toml
@@ -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
diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs
new file mode 100644
index 0000000..484462b
--- /dev/null
+++ b/crates/cb-render-html/src/jsrun.rs
@@ -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 `` body out of a document, in order.
+pub fn scripts(html: &str) -> Vec {
+ let mut out = Vec::new();
+ let mut rest = html;
+ while let Some(i) = rest.find("") {
+ 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, String> {
+ let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?;
+
+ // `Arc>` rather than `Rc>`: quick-js requires the
+ // callback to be unwind-safe, since a panic inside it would cross the
+ // C boundary.
+ let posted: Arc>> = 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 ", "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]);
+ }
+}
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index d910f7e..c39b300 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -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};
diff --git a/decisions/ADR-0009-embed-the-js-engine.md b/decisions/ADR-0009-embed-the-js-engine.md
new file mode 100644
index 0000000..f41f32b
--- /dev/null
+++ b/decisions/ADR-0009-embed-the-js-engine.md
@@ -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.
diff --git a/tools/cb-play/Cargo.toml b/tools/cb-play/Cargo.toml
index 3bafd06..63afc67 100644
--- a/tools/cb-play/Cargo.toml
+++ b/tools/cb-play/Cargo.toml
@@ -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
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index f53fdd4..db9c45b 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -370,4 +370,60 @@ 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());
+ }
}
diff --git a/tools/status.py b/tools/status.py
index 5dd7c05..5b5b411 100644
--- a/tools/status.py
+++ b/tools/status.py
@@ -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:
diff --git a/workplans/CB-WP-0014-execute-the-javascript.md b/workplans/CB-WP-0014-execute-the-javascript.md
index 31712fc..b5524be 100644
--- a/workplans/CB-WP-0014-execute-the-javascript.md
+++ b/workplans/CB-WP-0014-execute-the-javascript.md
@@ -55,7 +55,7 @@ is the real check.
```task
id: CB-WP-0014-T01
-status: todo
+status: done
priority: high
```
@@ -84,11 +84,32 @@ an automatic pass:
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: todo
+status: done
priority: high
```
@@ -110,6 +131,31 @@ has closed that loop.
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 `