CB-WP-0017: legible interaction, and the chaos window's verdict
Some checks failed
ci / check (push) Failing after 4s

Provenance (tier M, structural S, chaos d4=4 -> OVERRIDE drawn M):
the maintainer could drag after CB-WP-0016 but could not tell what was
pickable, held, or droppable. Underneath that, the page was WRONG about
which moves exist: 9 legal commands rendered as 5 cards each claiming
all three target kinds, from a const string in the emitter. Investigate
is legal on problems 2 and 3 but not 1; Solve on 1 but not 2 or 3. The
live page now says 'Solve onto problem 1'.

ADR-0010 restates control 5, which this work would otherwise have
outgrown in silence: every game fact the page acts on must arrive from
Rust as data; the script may read, match and render it, never compute,
infer, filter or default one. The survey's real finding is that the
permitted and forbidden designs are indistinguishable from outside, so
the vocabulary grep is demoted to a cheap first line and two behavioural
properties become the controls -- the highlighted set EQUALS the set
Rust emitted, and anything the page marks legal must resolve. Both
mutation-proven; the derive-legality mutation produces a plausible
highlight (seat-0,1,2 where only seat-1 is legal) and is caught.

Visible now: .pick resting shadow, .held on the grabbed element, .dropok
on every legal target including BOTH drawings of a seat, and a ghost
following the pointer. Nothing perceptual is verified and ADR-0010 D5
says so.

The DOM stub now models classList/querySelectorAll/createElement and
builds its node set from the real emitted page. Trap recorded: QuickJS
fixes its stack limit at Context creation relative to that frame, so a
helper returning a Context makes every later eval report
'SyntaxError: stack overflow'.

CHAOS WINDOW CLOSED, 12 declarations, 2 overrides, one each way. Both
changed the outcome, so the retirement condition is not met. Verdict:
keep, and recommend d4 -> d8 with a second window of 12 -- that is a
change to the loop's own constraints and is owed to the next declaration
as tier-M work, not made here.

CB-EV-0014 corrected: it quoted CB-WP-0015 at $15.14/136 and called it
the first settled figure quoted. Now $22.70/166. The number had been
read during CB-WP-0015 itself, so there are two defects -- the boundary,
and quoting from memory instead of re-running the instrument.

make all exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-02 22:42:45 +02:00
parent ca92db53bc
commit a55e878bf0
9 changed files with 911 additions and 77 deletions

View file

@ -64,14 +64,17 @@ 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. The first run of it (2026-08-02)
found the table legible and the drag **broken**: drop targets were
`id`s, an `id` must be unique, so the relationship-graph circle held
`seat-0` and the seat card the page points at had none. Fixed in
CB-WP-0016 — drop keys are `data-drop` — and verified by tests, by
mutation, and against a live server, but **not** by a human dragging,
which is the standard that found it. Run `cb-play --serve 0`, open the
printed URL, and drag an action onto a seat card.*
*Open on one human verification, and every run of it so far has found
something no test could. 2026-08-02, run 1: the drag was broken — drop
targets were `id`s, which must be unique, so the graph circle held
`seat-0` and the seat card had none (CB-WP-0016). Run 2: the drag
worked but the page was **wrong about which moves exist**, showing 5
cards each claiming all three target kinds where 9 specific commands
were legal, with no way to see what was pickable, held, or droppable
(CB-WP-0017). Both fixed. What remains is perceptual and unreachable
from here by construction (ADR-0010 D5): run `cb-play --serve 0` and
confirm you can see what can be picked up, what you are holding, and
where it may go.*
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

@ -56,34 +56,85 @@ fn cards(list: &[games_ground::SolutionCard]) -> String {
/// The only JavaScript in the project. See the module docs.
pub const SCRIPT: &str = r#"
(function () {
var down = null;
function key(e) {
var down = null, held = null, marked = [], ghost = null;
function node(e) {
var n = e.target;
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n ? n.getAttribute('data-drop') : null;
return n;
}
document.addEventListener('pointerdown', function (e) { down = key(e); });
function key(n) { return n ? n.getAttribute('data-drop') : null; }
function status(t) { document.getElementById('cb-status').textContent = t; }
// ADR-0010 D1: the destinations come from `data-targets`, which Rust
// wrote. This matches on them. It does not compute, infer, filter or
// default one -- a script that pattern-matched ids to guess what is
// legal would be forbidden even though the visible result is identical.
function mark(n) {
var spec = n.getAttribute('data-targets');
if (!spec) { return; }
var want = spec.split(' ');
var all = document.querySelectorAll('[data-drop]');
for (var i = 0; i < all.length; i++) {
if (want.indexOf(all[i].getAttribute('data-drop')) >= 0) {
all[i].classList.add('dropok');
marked.push(all[i]);
}
}
}
function clear() {
for (var i = 0; i < marked.length; i++) { marked[i].classList.remove('dropok'); }
marked = [];
if (held) { held.classList.remove('held'); held = null; }
if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); }
ghost = null;
down = null;
}
document.addEventListener('pointerdown', function (e) {
clear();
var n = node(e);
down = key(n);
if (!n || !down) { return; }
held = n;
n.classList.add('held');
mark(n);
if (n.getAttribute('data-targets')) {
ghost = document.createElement('div');
ghost.id = 'cb-ghost';
ghost.textContent = n.textContent;
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
document.body.appendChild(ghost);
}
});
document.addEventListener('pointermove', function (e) {
if (!ghost) { return; }
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
});
document.addEventListener('pointercancel', clear);
document.addEventListener('pointerup', function (e) {
var up = key(e);
if (!down || !up) {
// CB-WP-0016 T02: refusing is right; refusing SILENTLY is what let a
// broken drop target survive a human sitting in front of it. Report
// the raw fact — which element the pointer took and where it let go.
// This decides nothing: it names elements, not moves.
document.getElementById('cb-status').textContent =
down ? 'took ' + down + ', let go over nothing droppable'
: 'nothing droppable under the pointer';
down = null;
var up = key(node(e));
var grabbed = down;
clear();
if (!grabbed || !up) {
// Refusing is right; refusing SILENTLY is what let a broken drop
// target survive a human sitting in front of it (CB-WP-0016).
status(grabbed ? 'took ' + grabbed + ', let go over nothing droppable'
: 'nothing droppable under the pointer');
return;
}
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
down = null;
var body = 'down=' + encodeURIComponent(grabbed) + '&up=' + encodeURIComponent(up);
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;
status(t);
if (t.indexOf('ok') === 0) { window.location.reload(); }
});
});
@ -98,11 +149,51 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
.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}
/* CB-WP-0017. Interactive and inert must not look identical: `.pick` is
the resting affordance on anything that can be picked up. ADR-0010 D5
claims no evidence that this READS as pickable -- that is a human
check, and stage 1 is open on it. */
.pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset;
transition:transform .08s,box-shadow .08s}
.pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)}
/* Held: the thing in your hand is lifted and dimmed where it used to be. */
.held{cursor:grabbing;opacity:.45;transform:scale(.97)}
/* A legal destination for the thing currently held -- and ONLY for that
thing. The set is written by Rust into data-targets; the script matches
it and never derives it (ADR-0010 D1). */
.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33}
.dropok text{fill:#cfe}
/* The ghost that follows the pointer, so a drag is not invisible. */
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem;
border-radius:6px;background:#243;border:1px solid #5a7;color:#dde;
font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b;
transform:translate(-50%,-140%)}
.k{color:#89a}
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
";
/// Render drop keys as something a player can read.
///
/// The keys are what the page posts; these are what it shows. Both come
/// from the same list, so they cannot disagree about which moves exist.
fn target_names(targets: &[String]) -> String {
let mut out: Vec<String> = Vec::new();
for t in targets {
out.push(match t.split_once('-') {
Some(("seat", n)) => n
.parse::<u8>()
.map(|n| seat_name(PlayerId(n)))
.unwrap_or_else(|_| t.clone()),
Some(("problem", n)) => format!("problem {n}"),
_ if t == "table" => "the table".to_string(),
_ => t.clone(),
});
}
out.join(", ")
}
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"),
@ -232,7 +323,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
);
let _ = write!(
out,
"<span data-drop=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
"<span data-drop=\"freedom-{raw}\" class=\"act pick\"><span class=\"k\">freedom</span> \
{ready}{lifted}</span><br>",
raw = id.0,
ready = if p.freedom_ready { "READY" } else { "spent" },
@ -430,17 +521,33 @@ pub fn document(
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))
// ADR-0010 D1: the legal targets come from `legal` and are
// written into the page as data. The script matches on them;
// it never derives them. The old text was a CONSTANT —
// "onto a seat, a problem, or the table" — emitted whenever
// any legal command used this action, and it was wrong
// wherever the real target set was narrower, which is almost
// everywhere: Investigate is legal on problems 2 and 3 but
// not 1 (CB-WP-0017).
let targets: Vec<String> = seat
.map(|seat| {
legal
.iter()
.filter_map(|c| crate::input::affordance(c, seat))
.filter(|(f, _)| *f == action_id(a))
.map(|(_, t)| t)
.collect()
})
});
if offered {
.unwrap_or_default();
if !targets.is_empty() {
let _ = write!(
s,
"<div class=\"card act\" data-drop=\"{id}\">drag {a:?} onto a seat, a problem, \
or the table</div>",
"<div class=\"card act pick\" data-drop=\"{id}\" \
data-targets=\"{targets}\">{a:?}<br>\
<span class=\"k\">onto</span> {names}</div>",
id = action_id(a),
targets = esc(&targets.join(" ")),
names = esc(&target_names(&targets)),
);
}
}
@ -450,7 +557,7 @@ pub fn document(
if !spatial {
let _ = write!(
s,
"<div class=\"card btn\" data-drop=\"cmd-{i}\">{}</div>",
"<div class=\"card btn pick\" data-drop=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}"))
);
}
@ -461,7 +568,9 @@ pub fn document(
);
}
if may_pass {
s.push_str("<div class=\"card btn\" data-drop=\"pass\">pass \u{2014} decline to act</div>");
s.push_str(
"<div class=\"card btn pick\" data-drop=\"pass\">pass \u{2014} decline to act</div>",
);
}
let _ = write!(

View file

@ -55,32 +55,79 @@ pub fn scripts(html: &str) -> Vec<String> {
const DOM: &str = r#"
var __handlers = {};
var __status = { textContent: "" };
var __body = { children: [], appendChild: function (n) { this.children.push(n); n.parentNode = this; },
removeChild: function (n) { var i = this.children.indexOf(n);
if (i >= 0) { this.children.splice(i, 1); }
n.parentNode = null; } };
var __all = [];
// A DOM node with a real classList and real attributes. CB-WP-0016 found
// that a stub too thin to express a failure is how the failure survives;
// the previous stub could not express class-toggling at all.
function __mk(key, targets, text) {
var n = {
parentNode: null,
textContent: text || key,
style: {},
_cls: {},
_attr: { 'data-drop': key, 'data-targets': targets || null },
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
setAttribute: function (a, v) { this._attr[a] = v; },
classList: {
add: function (c) { n._cls[c] = true; },
remove: function (c) { delete n._cls[c]; },
contains: function (c) { return !!n._cls[c]; }
}
};
return n;
}
function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; }
var document = {
body: __body,
addEventListener: function (type, fn) { __handlers[type] = fn; },
getElementById: function (id) { return __status; }
getElementById: function (id) { return __status; },
createElement: function (_tag) { return __mk(null, null, ""); },
querySelectorAll: function (_sel) { return __all; }
};
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;
}
// A node carrying one data-drop key, with a null parent — so the
// script's walk up the tree terminates the way it does in a browser.
function __node(k) {
return {
getAttribute: function (a) { return a === 'data-drop' ? k : null; },
parentNode: null
};
// Gestures are driven against REGISTERED nodes, so the script's walk, its
// classList writes and its querySelectorAll all see the same objects a
// browser would -- rather than a synthetic {target:{id}} that bypasses
// every one of them (CB-EV-0014 section 2).
function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); }
function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); }
function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } }
function __cancel() { __handlers['pointercancel']({ target: __find(null) }); }
function __find(k) {
for (var i = 0; i < __all.length; i++) {
if (__all[i].getAttribute('data-drop') === k) { return __all[i]; }
}
return __mk(null, null, "");
}
function __down(k) { __handlers['pointerdown']({ target: __node(k) }); }
function __up(k) { __handlers['pointerup']({ target: __node(k) }); }
// A node with no key at all, whose parent chain ends: what the pointer
// lands on when it is dropped somewhere that is not a target.
function __downNowhere() { __handlers['pointerdown']({ target: __node(null) }); }
function __upNowhere() { __handlers['pointerup']({ target: __node(null) }); }
function __downNowhere() { __handlers['pointerdown']({ target: __mk(null, null, ""), clientX: 0, clientY: 0 }); }
function __upNowhere() { __handlers['pointerup']({ target: __mk(null, null, ""), clientX: 0, clientY: 0 }); }
function __marked() {
var out = [];
for (var i = 0; i < __all.length; i++) {
if (__all[i].classList.contains('dropok')) { out.push(__all[i].getAttribute('data-drop')); }
}
return out.sort().join(',');
}
function __heldKeys() {
var out = [];
for (var i = 0; i < __all.length; i++) {
if (__all[i].classList.contains('held')) { out.push(__all[i].getAttribute('data-drop')); }
}
return out.sort().join(',');
}
function __ghosts() { return __body.children.length; }
"#;
/// Run the document's scripts, then a pointer gesture, and report what
@ -105,16 +152,7 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String>
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()))?;
}
prepare(&ctx, html)?;
// 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
@ -137,6 +175,73 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String>
Ok(out)
}
/// Every droppable in the document, as `(data-drop, data-targets)`.
///
/// The stub's node set is built from the **real emitted page**, not from a
/// list the harness invented. CB-EV-0014 §2: the old stub synthesized
/// `{target:{id}}` from whatever id the test passed in, so it could not
/// tell whether the page had such an element at all.
pub fn droppables(html: &str) -> Vec<(String, Option<String>)> {
let mut out = Vec::new();
let mut rest = html;
while let Some(i) = rest.find("data-drop=\"") {
let after = &rest[i + 11..];
let Some(j) = after.find('"') else { break };
let key = after[..j].to_string();
// `data-targets`, when present, is written on the same element, so
// it is between here and the end of this tag.
let tail = &after[j..];
let end = tail.find('>').unwrap_or(tail.len());
let targets = tail[..end].find("data-targets=\"").and_then(|k| {
let t = &tail[k + 14..];
t.find('"').map(|e| t[..e].to_string())
});
out.push((key, targets));
rest = tail;
}
out
}
/// Install the DOM stub, register the page's droppables, and evaluate the
/// page's own scripts into a context the **caller** created.
///
/// **The caller must own the `Context`, and that is not a style
/// preference.** QuickJS records its stack limit at runtime creation,
/// relative to the frame it was created in, and later checks the current
/// frame against it. Creating the context inside a helper and returning it
/// leaves every subsequent `eval` running in a *shallower* frame, the
/// comparison underflows, and QuickJS reports `SyntaxError: stack
/// overflow` for even `__all.length`. Cost an hour; recorded so it costs
/// nobody else one.
///
/// Shared so a test cannot set up a different page than the harness runs:
/// one hand-rolled setup had already skipped registration silently.
pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?;
register(ctx, html)?;
let found = scripts(html);
if found.is_empty() {
return Err("the document carries no <script> block".to_string());
}
for (i, sc) in found.iter().enumerate() {
ctx.eval(sc)
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
}
Ok(())
}
fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
for (key, targets) in droppables(html) {
let call = match targets {
Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)),
None => format!("__register({}, null);", json_lit(&key)),
};
ctx.eval(&call)
.map_err(|e| format!("register {key}: {e}"))?;
}
Ok(())
}
fn json_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
@ -296,7 +401,6 @@ mod tests {
/// survived until a human tried it.
#[test]
fn a_drop_on_nothing_reports_instead_of_going_quiet() {
let html = page();
let ctx = quick_js::Context::new().expect("ctx");
let posted = Arc::new(Mutex::new(0usize));
let sink = posted.clone();
@ -306,10 +410,8 @@ mod tests {
})
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
ctx.eval(DOM).expect("dom");
for s in scripts(&html) {
ctx.eval(&s).expect("script");
}
prepare(&ctx, &page()).expect("prepare");
ctx.eval("__down('action-attack'); __upNowhere();")
.expect("gesture");
@ -325,6 +427,115 @@ mod tests {
);
}
/// Setup shared by the drag-visibility tests.
fn ctx_for(html: &str) -> quick_js::Context {
let ctx = quick_js::Context::new().expect("ctx");
ctx.add_callback("__post", |_u: String, _b: String| 0i32)
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
prepare(&ctx, html).expect("prepare");
ctx
}
/// **ADR-0010 Decision 2, property 1.** The highlighted set must equal
/// the set Rust emitted — not a subset, not a superset.
///
/// This is the load-bearing control, and it replaces the vocabulary
/// grep as the thing that actually holds Decision 1. A script that
/// *derived* legality by pattern-matching ids would produce a
/// plausible highlight and drift from `data-targets` the moment the
/// two disagreed; this is stated over observable behaviour rather than
/// over source text, which is the same move that fixed control 5.
#[test]
fn picking_up_marks_exactly_the_targets_rust_emitted() {
let html = page();
let ctx = ctx_for(&html);
// What Rust wrote on the card, read back out of the document.
let emitted: String = droppables(&html)
.into_iter()
.find(|(k, _)| k == "action-attack")
.and_then(|(_, t)| t)
.expect("the action card carries its legal targets");
let mut want: Vec<&str> = emitted.split(' ').collect();
want.sort_unstable();
ctx.eval("__down('action-attack');").expect("pointerdown");
let marked: String = ctx.eval_as("__marked()").expect("marked");
// Compared as SETS, because a seat is drawn twice — card and graph
// node — and CB-WP-0016 made both droppable. Marking both is
// correct and the first draft of this assertion was wrong about
// it: it read `seat-1,seat-1` and expected `seat-1`.
let mut got: Vec<&str> = marked.split(',').filter(|s| !s.is_empty()).collect();
got.sort_unstable();
got.dedup();
assert_eq!(got, want, "emitted {emitted:?}, marked {marked:?}");
// And the duplication is asserted rather than tolerated: a seat
// that highlighted on only one of its two drawings would send the
// player back to hunting for the one that works.
assert_eq!(
marked.matches("seat-1").count(),
2,
"both drawings of a seat must highlight, got {marked:?}"
);
// And it is not simply marking everything.
let all: i32 = ctx.eval_as("__all.length").expect("count");
assert!(
(want.len() as i32) < all,
"the page marked every droppable, which proves nothing"
);
}
/// The grabbed element is visibly held, and a ghost follows the
/// pointer — otherwise a drag is invisible and the maintainer cannot
/// tell it is happening (CB-WP-0017).
#[test]
fn the_grabbed_element_is_held_and_a_ghost_follows_the_pointer() {
let ctx = ctx_for(&page());
ctx.eval("__down('action-attack');").expect("pointerdown");
assert_eq!(
ctx.eval_as::<String>("__heldKeys()").expect("held"),
"action-attack"
);
assert_eq!(ctx.eval_as::<i32>("__ghosts()").expect("ghosts"), 1);
ctx.eval("__move();").expect("pointermove");
let left: String = ctx
.eval_as("__body.children[0].style.left")
.expect("ghost position");
assert_eq!(left, "9px", "the ghost did not follow the pointer");
}
/// A drag that ends leaves no residue. Without this the next drag
/// highlights a stale set, which is worse than no highlight at all.
#[test]
fn ending_a_drag_clears_every_mark_however_it_ends() {
for ending in ["__up('seat-1');", "__upNowhere();", "__cancel();"] {
let ctx = ctx_for(&page());
ctx.eval("__down('action-attack');").expect("pointerdown");
assert_ne!(ctx.eval_as::<String>("__marked()").expect("m"), "");
ctx.eval(ending).expect("ending");
assert_eq!(
ctx.eval_as::<String>("__marked()").expect("m"),
"",
"marks survived {ending}"
);
assert_eq!(
ctx.eval_as::<String>("__heldKeys()").expect("h"),
"",
"held survived {ending}"
);
assert_eq!(
ctx.eval_as::<i32>("__ghosts()").expect("g"),
0,
"the ghost survived {ending}"
);
}
}
#[test]
fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page());

View file

@ -467,6 +467,69 @@ mod affordances {
assert!(n >= 50, "checked only {n} decision point(s)");
}
/// **ADR-0010 Decision 2, property 2.** A target the page marks legal
/// must resolve.
///
/// If the page can advertise a drop that Rust then refuses, the two
/// have drifted and the highlighting is *worse* than none — it teaches
/// the player something false. This walks real games and checks the
/// emitted `data-targets` against `resolve` itself, so the page's
/// promise and the referee's answer cannot disagree.
#[test]
fn everything_the_page_advertises_actually_resolves() {
let checked = Rc::new(RefCell::new(0usize));
for seed in 0..4u64 {
let mut policies: Vec<Box<dyn Policy>> = (0..3)
.map(|i| {
Box::new(AdvertisedPolicy {
inner: RandomPolicy::new(seed * 7 + i),
checked: checked.clone(),
}) as Box<dyn Policy>
})
.collect();
play(fresh(seed), &mut policies).expect("a bot game completes");
}
let n = *checked.borrow();
assert!(n >= 50, "checked only {n} advertised target(s)");
}
struct AdvertisedPolicy {
inner: RandomPolicy,
checked: Rc<RefCell<usize>>,
}
impl Policy for AdvertisedPolicy {
fn name(&self) -> &'static str {
"advertised-target-checking"
}
fn choose(
&mut self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
may_pass: bool,
) -> Choice {
let view = state.project(Viewer::Player(seat));
let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass);
for (grab, targets) in crate::jsrun::droppables(&html) {
let Some(spec) = targets else { continue };
for drop in spec.split(' ').filter(|s| !s.is_empty()) {
let fact = crate::PointerFact::new(&grab, drop);
assert!(
crate::resolve(&fact, legal, seat).is_ok(),
"the page advertises {grab} -> {drop} but resolve refuses it \
(seat {seat:?}, step {:?})",
state.step
);
*self.checked.borrow_mut() += 1;
}
}
self.inner.choose(state, seat, legal, may_pass)
}
}
/// **The regression test for the defect actually reported**, and the
/// reason the check above is not sufficient on its own.
///

View file

@ -0,0 +1,136 @@
# ADR-0010: the script may render state it was given; it may not derive any
status: accepted
date: 2026-08-02
decided by: agent, under the standing loop authorization
tier: M (structural S — presentation work inside an existing capability;
chaos d4=4 → OVERRIDE, drawn M. InnerLoop v1.6). Tier M merges survey and
decision into one document, which this is.
references: [CB-WP-0017](../workplans/CB-WP-0017-legible-interaction.md),
[ADR-0007](ADR-0007-render-html-not-a-port.md) D5 (control 5),
[CB-EV-0014](../evidence/CB-EV-0014-the-drop-target.md) §4
## Context
ADR-0007 Decision 5 constrains the page:
> the page reports **raw pointer facts and nothing else**, and Rust decides
> what command they mean.
and `doc.rs` says of `SCRIPT`: *"it does one thing"*. Every request in
CB-WP-0017 needs it to do more than one thing — hold a drag in flight,
follow the pointer, and add and remove classes on **other** elements.
So the constraint has to be restated or the work does not happen. Restating
a control in order to permit the thing it forbade is how controls die, and
this ADR exists so that happens out loud or not at all.
## The survey: three ways to make interaction legible
| option | where legality is decided | script size |
|---|---|---|
| **A. Rust emits legality as data; the script toggles classes** | Rust | ~2× today |
| B. The script derives legality from the rendered page | **the page** | ~3× |
| C. Server round-trip on pointerdown to ask what is legal | Rust | ~1.5×, plus a request per grab |
**B is the one control 5 exists to forbid**, and it is not hypothetical:
the page already contains every action id and every target id, so a script
*could* infer plausible pairs. It would be wrong in exactly the cases that
matter — Investigate is legal on problems 2 and 3 but not 1, and no amount
of looking at the DOM reveals why.
**C is honest and too slow.** A highlight that arrives after a network
round trip is not a highlight; it also makes a pointerdown a state-changing
request, which the token guard's threat model did not consider.
**A is the decision.** But the survey's real output is that A and B are
*indistinguishable from the outside*: both are "the page highlights some
elements". A control that cannot tell them apart is not a control.
## Decision 1 — the script may render state it was given; it may not derive any
The replacement for *"it does one thing"*:
> **Every game fact the page acts on must arrive from Rust as data.** The
> script may read it, match it, and turn it into presentation. It may not
> compute, infer, filter, or default one.
Under this rule, class-toggling is permitted: the legal target set is a
`data-targets` attribute Rust wrote, and the script does string matching on
it. Deriving that set by pattern-matching ids would be forbidden even
though the visible result is identical.
## Decision 2 — the honest argument, and why the old test was weak
The tempting defence is *"the page only renders what Rust said"*. That is
exactly what a page constructing commands would also say, so it earns
nothing on its own.
The existing control is a test that greps `SCRIPT` for game vocabulary.
CB-WP-0014 already found that shape too weak once — it was replaced for
control 5 by an assertion on **what the script puts on the wire**. The same
weakness applies here and is now worse, because the script legitimately
handles more.
So the vocabulary grep stays as a cheap first line, and the load-bearing
control becomes a pair of properties a test can check:
1. **The highlighted set equals the set Rust emitted.** Not a subset, not a
superset. A script that derived legality would drift from the attribute
the moment the two disagreed, and this catches that with no reference to
how the script is written.
2. **A target the page marks legal must `resolve`.** If the page can
advertise a drop that Rust then refuses, the two have drifted and the
highlighting is worse than none — it would teach the player something
false.
Property 1 is the real replacement for the grep. It is stated over
observable behaviour rather than over source text, which is the same move
that fixed control 5.
## Decision 3 — what the script may still never do
Unchanged from ADR-0007 D5, and restated because this ADR widens the rest:
- it may not name an action, a seat, a rule, or a phase;
- it may not construct a command, in any encoding;
- what it posts stays **exactly two fields**, `down` and `up`, carrying two
element keys and nothing derived from them.
The body-shape assertion from CB-WP-0014 continues to hold this, and
nothing in this ADR touches it.
## Decision 4 — the size of the script is not the control
`SCRIPT` roughly doubles. That is not a violation of anything: ADR-0007's
concern was *rules leaking into the page*, not line count, and it said so —
*"if a rule ever needs to appear in it, the decision is wrong"*. Marginal
AM-4a cost is zero either way, so the dependency budget does not decide
this.
What decides it is Decision 1's rule plus the two properties in Decision 2.
Recording this explicitly so that a future pass does not cite the growth as
either a licence or an objection.
## Decision 5 — what this ADR does not claim
**No visual property here is verified.** Whether a shadow reads as *"you
can pick this up"*, whether a highlight is noticeable, whether a drag feels
followable — none of that is reachable by any test in this repo, and
CB-WP-0016 is the standing evidence for what happens when that gap is
papered over.
The testable core is the *correspondence* between what the page marks and
what Rust will accept. The perceptual half stays a human check, and stage 1
stays open on it.
## Consequences
- The JS DOM stub must model `classList` and element lookup. A stub too
thin to express a failure is how the failure survives (CB-EV-0014 §2),
and today's stub cannot express any of this.
- The vocabulary grep is now explicitly the *weak* control, kept for cost.
If it ever fails while property 1 passes, prefer property 1 and say so.
- If a later pass needs the script to make a decision Rust cannot pre-empt
— an animation that depends on rules, say — Decision 1 forbids it and
this ADR is what to revisit.

View file

@ -113,14 +113,21 @@ would have missed it.
| pass | kind | responses | cost | $/response |
|---|---|---|---|---|
| **CB-WP-0015** | product | 136 | **$15.14** | 0.111 |
| **CB-WP-0015** | product | ~~136~~ **166** | ~~$15.14~~ **$22.70** | ~~0.111~~ **0.137** |
| CB-WP-0016 | product | *provisional — not quoted* | | |
CB-EV-0013 §5 found the self-quoting rule fixes the wrong boundary: a
pass's window runs to the *next* pass's first commit, so CB-WP-0015's
figure was still open when quoted. It is quoted here after CB-WP-0016's
declaration commit closed it, which is the first figure this project has
quoted at a boundary that had actually settled.
**CORRECTED 2026-08-02 (CB-EV-0015 §6).** This file originally reported
CB-WP-0015 at **$15.14 / 136** and claimed that figure was *"the first
this project has quoted at a boundary that had actually settled."* **The
claim was false and the number was wrong.** $15.14/136 was read from
`make status` during CB-WP-0015 itself and carried forward — it was that
pass's own in-flight figure, which is precisely what CB-EV-0012's rule
exists to forbid. Measured after CB-WP-0017's declaration closed the
window: **$22.70 / 166**, higher by 50%.
So the defect is not only the boundary CB-EV-0013 §5 identified. It is
that a figure gets *read once and quoted later*, and a number read earlier
in a session is an in-flight number no matter which pass's name is on it.
## 6. Open

View file

@ -0,0 +1,221 @@
# CB-EV-0015 — legible interaction, and the chaos window's verdict
CB-WP-0017 T04. Measured 2026-08-02 at `ca92db5`+. Pass kind `product`,
tier **M** (structural S, **chaos d4=4 → OVERRIDE, drawn M**).
**Declaration 12 of 12 — the chaos calibration window closes here.**
---
## 1. The page was lying, and now it is not
The maintainer could drag after CB-WP-0016 but could not tell what was
draggable, what was held, where it could go, or what any of it would do.
Underneath that was something worse than a missing affordance: the page
was **wrong about which moves exist**.
Seat P1, round 1, step Select — **9 legal commands**, rendered as 5 cards
each claiming all three target kinds. Before and after, from the live
server:
| card | before | after |
|---|---|---|
| Investigate | *"onto a seat, a problem, or the table"* | **onto problem 2, problem 3** |
| Solve | *"onto a seat, a problem, or the table"* | **onto problem 1** |
| Attack | *"onto a seat, a problem, or the table"* | **onto P2, P3** |
| Support | *"onto a seat, a problem, or the table"* | **onto P2, P3** |
| Ground | *"onto a seat, a problem, or the table"* | **onto the table** |
Investigate is legal on problems 2 and 3 but **not** 1; Solve on 1 but not
2 or 3. The old text was a `const` in the emitter, printed whenever *any*
legal command used that action. `legal` carried the exact targets and the
renderer threw them away.
So drop-target highlighting is not decoration on a working table. It is
the first time the page tells the truth about what is legal.
## 2. ADR-0010: the constraint that replaced "it does one thing"
ADR-0007 D5 said the page *"reports raw pointer facts and nothing else"*
and `doc.rs` said `SCRIPT` *"does one thing"*. Everything asked for here
needs it to do more — hold a drag, follow the pointer, mark other
elements. Restating a control in order to permit what it forbade is how
controls die, so it was restated out loud:
> **Every game fact the page acts on must arrive from Rust as data.** The
> script may read it, match it, and turn it into presentation. It may not
> compute, infer, filter, or default one.
The survey's real finding: the permitted design (Rust emits legality, the
script matches it) and the forbidden one (the script derives legality from
the DOM) are **indistinguishable from the outside** — both are "the page
highlights some elements". A control that cannot separate them is not a
control.
The old control was a grep of `SCRIPT` for game vocabulary. CB-WP-0014
already found that shape too weak once. It is now demoted to a cheap first
line, and the load-bearing control is stated over behaviour:
**Property 1 — the highlighted set equals the set Rust emitted.** The
mutation that makes the script derive targets by pattern-matching ids
produces a *plausible* highlight — `seat-0, seat-1, seat-2` where only
`seat-1` is legal — and the test catches it. That is Decision 1 actually
enforced rather than asserted.
**Property 2 — a target the page marks legal must `resolve`.** A page that
advertises a drop Rust then refuses is worse than no highlighting, because
it teaches the player something false. Mutation: adding `table` to
Investigate's targets → *"the page advertises action-investigate -> table
but resolve refuses it"*.
## 3. What is visible now, and what is merely tested
| request | done | verified how |
|---|---|---|
| what can be picked up | `.pick` — shadow, lift on hover, `grab` cursor | **not verified.** Whether it *reads* as pickable is a human check |
| what is held | `.held` — dimmed and scaled where it sat | JS test: `__heldKeys()` is exactly the grabbed key |
| where it can go | `.dropok` on every legal target, **both drawings of a seat** | JS test: set equality with `data-targets` |
| where it is going | a ghost following the pointer | JS test: ghost exists, and its `left` tracks `clientX` |
| what moves exist | the card names its real targets | live server, §1 |
**A seat is marked on both of its drawings** — card and graph node. That
is asserted rather than tolerated: highlighting only one would send the
player back to hunting for the one that works, which is the CB-WP-0016
defect in a new form. The first draft of that assertion was *wrong* about
it, expecting `seat-1` and reading `seat-1,seat-1`.
**Third mutation:** dropping the `pointercancel` handler and the `clear()`
on pointerup leaves marks behind — *"marks survived `__up('seat-1')`"*. A
stale highlight set is worse than none.
### What this pass does not claim
No visual property is verified. Whether the shadow reads as *"pick me
up"*, whether the highlight is noticeable, whether the drag feels
followable — none of it is reachable from here, and ADR-0010 D5 says so
rather than letting the tests imply otherwise.
## 4. The harness had to get deeper, and cost an hour to a real trap
The DOM stub could not express any of this: it synthesized
`{target:{id}}` from whatever key the test passed. It now models
`classList`, `querySelectorAll`, `createElement`, and a body — **and its
node set is built from the real emitted page** via `droppables(html)`,
not from a list the harness invented.
**The trap, recorded because it will recur:** QuickJS fixes its stack
limit at `Context` creation *relative to the frame it was created in*. A
helper that creates a context and returns it leaves every later `eval`
running in a shallower frame, the comparison underflows, and QuickJS
reports `SyntaxError: stack overflow` — for `__all.length`. The fix is
that the caller owns the `Context` and the helper only prepares it.
That is also why one test had silently skipped registration: each test
hand-rolled its setup. `prepare()` is now shared, so a test cannot set up
a different page than the harness runs.
## 5. The chaos calibration window: the verdict it has owed
`gates.toml` states the retirement condition in its own words:
> *"the window closes with no overridden tier producing a different outcome
> than the argued one — the evaluation this window exists to make
> possible"*
Twelve declarations, **two overrides**, one in each direction. That is the
minimum that makes an evaluation possible at all, and it arrived on the
last declaration.
### Override 1 — CB-WP-0011, structural L → S
The deleted survey would have opened on 2D toolkits. What the pass found
instead, at tier S, was that the **existing** text renderer was showing 24
of 41 view fields (CB-EV-0009 §1) — a defect a toolkit survey would have
walked straight past. Priced on the same subject: **0.099 $/response at S
against 0.123 at L** (CB-EV-0010 §5).
**Different outcome than the argued one: yes.** The argued tier would have
bought a survey of alternatives; the rolled tier bought a defect in what
already existed.
### Override 2 — CB-WP-0017, structural S → M
The question the retirement condition asks: **what did the extra ADR buy
that a tier-S provenance paragraph would not have?**
It bought the restatement of control 5. At tier S this pass would have
widened what the JavaScript does — pointermove, class-toggling on other
elements, a ghost node — under a one-paragraph commit note, and ADR-0007
D5's *"reports raw pointer facts and nothing else"* would have been
silently outgrown rather than replaced. The forced document is what
produced Decision 1's rule and, more importantly, the finding that the
permitted and forbidden designs are indistinguishable from outside — which
is what turned the vocabulary grep from *the* control into a cheap first
line, and produced the two properties that are now the real controls.
**Different outcome than the argued one: yes**, and in the direction that
is harder to see from inside — the extra process caught a control quietly
expiring.
### Verdict: keep, at a lower rate
Both overrides changed the outcome, so the retirement condition is not
met. But the honest reading of the cost is that d4 fired twice in twelve
and both were informative *because they were rare*; a mechanism that
overrides a quarter of all declarations stops being a calibration and
starts being the tier system.
**Recommendation: keep the chaos roll, drop the rate from d4 to d8, and
open a second window of 12** with a new retirement condition — retire if
an override changes nothing twice running. This is a change to how the
loop constrains its own operation and is therefore **tier M work that this
pass does not do**: it is recorded here as the recommendation and owed to
the next declaration, which must make it with its own tier and its own
roll.
## 6. Cost
| pass | kind | responses | cost | $/response |
|---|---|---|---|---|
| **CB-WP-0016** | product | 64 | **$14.93** | 0.233 |
| CB-WP-0017 | product | *provisional — not quoted* | | |
Read from `make status` **at the moment of writing**, after CB-WP-0017's
declaration closed CB-WP-0016's window.
### The self-quoting rule failed a second way, and CB-EV-0014 is corrected
CB-EV-0014 §5 quoted CB-WP-0015 at **$15.14 / 136** and claimed it was
*"the first figure this project has quoted at a boundary that had actually
settled."* Both parts were wrong. The same tool now reports CB-WP-0015 at
**$22.70 / 166** — higher by 50%. CB-EV-0014 has been corrected in place.
$15.14/136 was read during CB-WP-0015 itself and carried forward into the
next pass's write-up. So there are **two** defects, not one:
1. the boundary is wrong — a window runs to the *next* pass's first commit
(CB-EV-0013 §5);
2. **a figure read earlier in a session is an in-flight figure regardless
of whose name is on it.** Quoting from memory defeats the rule even
when the boundary is right.
Five for five now, always low: every self-reported cost in this project
has been an underestimate. The rule owed is therefore not just *"quote two
passes back"* but *"re-run the instrument at the moment you quote it"*
and the second half is what this file did.
**Meta budget: 0% `[ok]`** over the trailing three, all product.
## 7. Open
- **INTENT stage 1: the human check, again.** Everything perceptual here
is unverified by construction. `cb-play --serve 0`: can you see what is
pickable, what you are holding, and where it may go?
- **Chaos: the d4→d8 recommendation** and a second window. §5.
- **The self-quoting rule still names the wrong boundary.** CB-EV-0013 §5.
- **AM-4b's scope defect (408,237 uncounted lines)** and its proc-macro
share.
- **`python3` as a toolchain dependency was never argued.**
- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000.
- **ADR-0007 D3's acquisition rule** unratified after deciding two
dependency questions; **ADR-0010** now rests on D5, which is also
unratified.

View file

@ -125,9 +125,22 @@ 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-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it",
"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"
# VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides).
# Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a
# defect in the existing renderer that the deleted survey would have walked
# past, at 0.099 $/response against 0.123. CB-WP-0017 (S→M) bought the
# restatement of control 5 — at tier S the script would have outgrown
# ADR-0007 D5 under a one-paragraph commit note.
# RECOMMENDED, and owed to the next declaration as tier-M work: drop the rate
# d4 → d8 and open a second window of 12. Both overrides were informative
# BECAUSE they were rare; a mechanism firing on a quarter of declarations
# stops being a calibration and becomes the tier system.
# New retirement condition proposed: retire if an override changes nothing
# twice running.
[[gate]]
id = "GATE-REVIEW"

View file

@ -2,7 +2,7 @@
id: CB-WP-0017
kind: product
title: "Legible interaction: what can be picked up, what is being dragged, where it can go"
status: todo
status: done
---
# Purpose
@ -66,7 +66,7 @@ It is the first time the page will tell the truth about what is legal.
```task
id: CB-WP-0017-T01
status: todo
status: done
priority: high
```
@ -91,11 +91,23 @@ Decide, explicitly:
- **The cost.** Marginal AM-4a is zero either way, so the budget does not
decide this. Say what does.
**Done 2026-08-02.**
[ADR-0010](../decisions/ADR-0010-what-the-script-may-do.md) — *the script
may render state it was given; it may not derive any.*
The survey's real output: the permitted design (Rust emits legality, the
script matches it) and the forbidden one (the script derives legality from
the DOM) are **indistinguishable from the outside** — both are "the page
highlights some elements". So the vocabulary grep is demoted to a cheap
first line and the load-bearing controls are stated over behaviour: the
highlighted set must **equal** the set Rust emitted, and anything the page
marks legal must **resolve**. Both are mutation-proven below.
## Task: tell the truth about which moves are legal
```task
id: CB-WP-0017-T02
status: todo
status: done
priority: high
```
@ -114,11 +126,20 @@ wrong.
advertise a target `resolve` then refuses, the two have drifted and the
highlighting is worse than none.
**Done 2026-08-02.** From the live server: *"Solve onto problem 1"*,
*"Investigate onto problem 2, problem 3"*, *"Attack onto P2, P3"*,
*"Ground onto the table"*. The constant sentence is gone.
`everything_the_page_advertises_actually_resolves` drives four real bot
games and checks every emitted target against `resolve` itself. Mutation —
adding `table` to Investigate's targets — goes red with *"the page
advertises action-investigate -> table but resolve refuses it"*.
## Task: make picking up, dragging, and dropping visible
```task
id: CB-WP-0017-T03
status: todo
status: done
priority: high
```
@ -149,11 +170,39 @@ The JS DOM stub cannot express any of this today. It will have to model
and CB-WP-0016 showed that a stub too thin to express a failure is how the
failure survives.
**Done 2026-08-02.** `.pick` (resting shadow, lift on hover), `.held`
(dimmed and scaled), `.dropok` (dashed outline on every legal target), and
a ghost following the pointer.
The stub now models `classList`, `querySelectorAll`, `createElement` and a
body, **and its node set is built from the real emitted page** via
`droppables(html)` rather than from a list the harness invented.
Three mutations, each red:
| mutation | result |
|---|---|
| the script *derives* targets from the DOM instead of reading `data-targets` | red — marks `seat-0, seat-1, seat-2` where only `seat-1` is legal. **A plausible-looking highlight, caught.** This is ADR-0010 D1 enforced |
| the page advertises a target `resolve` refuses | red |
| `pointercancel` and the `clear()` on pointerup are dropped | red — *"marks survived `__up('seat-1')`"* |
**A seat highlights on both of its drawings** — card and graph node — and
that is asserted rather than tolerated. The first draft of the assertion
was wrong about it: it expected `seat-1` and read `seat-1,seat-1`.
**A real trap, recorded:** QuickJS fixes its stack limit at `Context`
creation relative to the frame it was created in, so a helper that creates
a context and *returns* it makes every later `eval` report `SyntaxError:
stack overflow` — for `__all.length`. The caller must own the `Context`.
**Nothing perceptual is verified.** Whether a shadow reads as "pick me up"
is not reachable from here (ADR-0010 D5).
## Task: evidence, and close the chaos calibration window
```task
id: CB-WP-0017-T04
status: todo
status: done
priority: high
```
@ -184,3 +233,25 @@ Also due:
- **Whether stage 1 closes.** It needs a human to drag again. Say so.
- **Quote CB-WP-0016's cost**, and apply CB-EV-0013 §5's correction —
quote a boundary that has actually settled, or say it has not.
**Done 2026-08-02.**
[CB-EV-0015](../evidence/CB-EV-0015-legible-interaction.md). `make all`
exits 0.
- **The chaos window's verdict: keep, at a lower rate.** Both overrides
changed the outcome, so the retirement condition is not met. CB-WP-0011
(L→S) bought a defect in the existing renderer that a toolkit survey
would have walked past, at 0.099 $/response against 0.123. CB-WP-0017
(S→M) bought the restatement of control 5 — at tier S the script would
have outgrown ADR-0007 D5 under a one-paragraph commit note.
**Recommendation: d4 → d8, second window of 12, retire if an override
changes nothing twice running.** That is a change to how the loop
constrains its own operation, so it is tier-M work this pass does not
do — it is owed to the next declaration.
- **CB-EV-0014 is corrected.** It quoted CB-WP-0015 at $15.14/136 and
claimed that was the first settled figure this project had quoted. Both
wrong: it now reads **$22.70/166**. The number had been read during
CB-WP-0015 itself and carried forward, so there are two defects — the
boundary, and quoting a figure from memory rather than re-running the
instrument. Five for five, always low.
- **Stage 1 stays open**, and now on a purely perceptual question.