CB-WP-0016: the drop target that was never there
Some checks failed
ci / check (push) Failing after 4s

Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check that kept INTENT stage 1 open was run and the drag was broken.
Root cause, worth more than the instance: drop targets were ids, and an
id must be unique, so exactly one element could ever be seat-0. The
relationship-graph circle took it and the seat card that every action
card's own text points at -- 'drag Attack onto a seat' -- silently had
none. A seat is drawn twice and both drawings are the seat; the document
model could not express that.

Drop keys are now data-drop. Any number of elements may carry the same
key, so a seat is droppable on its card and on its graph node. Measured
on a live server: seat-0/1/2 each appear twice, id survives only on
cb-status which is the one element the script looks up, and
down=action-attack&up=seat-1 returns ok.

Second defect: a drop on nothing returned without posting and without
touching the status line, so a broken target was indistinguishable from
a working page. resolve already refuses rather than defaulting, which is
right; refusing SILENTLY is not. The page now reports the raw fact --
'took action-attack, let go over nothing droppable' -- which names
elements, not moves, so ADR-0007 control 5 holds.

And the honest part: the general check added here -- every offered
affordance names a key that exists, driven through Policy::choose over
four real bot games -- does NOT catch the reported defect. seat-0 did
exist, on the graph circle. It is kept because a wholly absent target is
a real class, and paired with a targeted regression test that does catch
it. Three mutations, each red for its stated reason, including the
reported defect reintroduced; only the targeted test fires on that one.

A cb-play assertion matched id="action-ground" as a substring while
describing itself as checking the page; rewritten through drop_keys.

make all exits 0. Stage 1 stays open: verified by tests, mutation and a
live server, not by a human dragging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-02 20:48:18 +02:00
parent 4df2d0a4ae
commit bf72a1863a
9 changed files with 496 additions and 27 deletions

1
Cargo.lock generated
View file

@ -91,6 +91,7 @@ dependencies = [
name = "cb-render-html" name = "cb-render-html"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cb-game-runtime",
"cb-kernel", "cb-kernel",
"games-ground", "games-ground",
"quick-js", "quick-js",

View file

@ -64,11 +64,14 @@ game.
and scenario tests, simple bots. No rendering, no physics. and scenario tests, simple bots. No rendering, no physics.
1. **Inspectable 2D table** — card/token/hand/relationship-graph 1. **Inspectable 2D table** — card/token/hand/relationship-graph
visualization, drag-to-propose, debug inspector, hot-seat play. visualization, drag-to-propose, debug inspector, hot-seat play.
*Open on one human verification (CB-EV-0012 §4): the inspector, *Open on one human verification. The first run of it (2026-08-02)
drag-to-propose and hot-seat play are evidenced by executing code; found the table legible and the drag **broken**: drop targets were
the visualization is evidenced only as correctly emitted, because no `id`s, an `id` must be unique, so the relationship-graph circle held
browser is available to the loop. Run `cb-play --serve 0`, open the `seat-0` and the seat card the page points at had none. Fixed in
printed URL, and confirm the table reads and a drag works.* 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.*
2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera 2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera
and pointer controls, snap zones, asset importer. and pointer controls, snap zones, asset importer.
3. **Networked sessions** — authoritative host, private projections, 3. **Networked sessions** — authoritative host, private projections,

View file

@ -25,6 +25,11 @@ quick-js = { version = "0.4", optional = true }
[dev-dependencies] [dev-dependencies]
# The coverage gate walks the serialized view; nothing else needs it. # The coverage gate walks the serialized view; nothing else needs it.
serde_json.workspace = true serde_json.workspace = true
# CB-WP-0016 T03 drives a real bot game to render at real decision points.
# A workspace crate, so AM-4 is unmoved: it counts third-party code.
# `scenarios` carries GroundState::setup, which the walk needs to deal a game.
cb-game-runtime = { workspace = true, features = ["scenarios"] }
games-ground = { workspace = true, features = ["scenarios"] }
quick-js = "0.4" quick-js = "0.4"
[lints] [lints]

View file

@ -57,15 +57,25 @@ fn cards(list: &[games_ground::SolutionCard]) -> String {
pub const SCRIPT: &str = r#" pub const SCRIPT: &str = r#"
(function () { (function () {
var down = null; var down = null;
function id(e) { function key(e) {
var n = e.target; var n = e.target;
while (n && !n.id) { n = n.parentNode; } while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n ? n.id : null; return n ? n.getAttribute('data-drop') : null;
} }
document.addEventListener('pointerdown', function (e) { down = id(e); }); document.addEventListener('pointerdown', function (e) { down = key(e); });
document.addEventListener('pointerup', function (e) { document.addEventListener('pointerup', function (e) {
var up = id(e); var up = key(e);
if (!down || !up) { down = null; return; } 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;
return;
}
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
down = null; down = null;
fetch(window.CB_ENDPOINT, { fetch(window.CB_ENDPOINT, {
@ -122,7 +132,7 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
}; };
let _ = write!( let _ = write!(
out, out,
"<g id=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \ "<g data-drop=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\ fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\ <text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\ <text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
@ -182,7 +192,7 @@ fn relations_svg(view: &GroundView) -> String {
.map(|f| format!(" \u{2192}{}", seat_name(*f))); .map(|f| format!(" \u{2192}{}", seat_name(*f)));
let _ = write!( let _ = write!(
s, s,
"<g id=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \ "<g data-drop=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
stroke=\"{stroke}\" stroke-width=\"2\"/>\ stroke=\"{stroke}\" stroke-width=\"2\"/>\
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \ <text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
text-anchor=\"middle\">{name}</text>\ text-anchor=\"middle\">{name}</text>\
@ -204,7 +214,13 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
let is_viewer = view.viewer == Some(id); let is_viewer = view.viewer == Some(id);
let _ = write!( let _ = write!(
out, out,
"<div class=\"card\" data-viewer=\"{is_viewer}\"><b>{name}</b>{you}<br>", // CB-WP-0016 T01: the seat card is a drop target. It could not be
// while drop keys were `id`s — the graph node had already taken
// `seat-{n}` and ids must be unique, so the card the instruction
// text points at silently had none.
"<div class=\"card\" data-viewer=\"{is_viewer}\" data-drop=\"seat-{raw}\">\
<b>{name}</b>{you}<br>",
raw = id.0,
name = seat_name(id), name = seat_name(id),
you = if is_viewer { " (you)" } else { "" }, you = if is_viewer { " (you)" } else { "" },
); );
@ -216,7 +232,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
); );
let _ = write!( let _ = write!(
out, out,
"<span id=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \ "<span data-drop=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
{ready}{lifted}</span><br>", {ready}{lifted}</span><br>",
raw = id.0, raw = id.0,
ready = if p.freedom_ready { "READY" } else { "spent" }, ready = if p.freedom_ready { "READY" } else { "spent" },
@ -422,7 +438,7 @@ pub fn document(
if offered { if offered {
let _ = write!( let _ = write!(
s, s,
"<div class=\"card act\" id=\"{id}\">drag {a:?} onto a seat, a problem, \ "<div class=\"card act\" data-drop=\"{id}\">drag {a:?} onto a seat, a problem, \
or the table</div>", or the table</div>",
id = action_id(a), id = action_id(a),
); );
@ -434,18 +450,18 @@ pub fn document(
if !spatial { if !spatial {
let _ = write!( let _ = write!(
s, s,
"<div class=\"card btn\" id=\"cmd-{i}\">{}</div>", "<div class=\"card btn\" data-drop=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}")) esc(&format!("{c:?}"))
); );
} }
} }
s.push_str( s.push_str(
"</div><div class=\"card\" id=\"table\">the table \u{2014} drop here for an \ "</div><div class=\"card\" data-drop=\"table\">the table \u{2014} drop here for an \
untargeted action</div>", untargeted action</div>",
); );
} }
if may_pass { if may_pass {
s.push_str("<div class=\"card btn\" id=\"pass\">pass \u{2014} decline to act</div>"); s.push_str("<div class=\"card btn\" data-drop=\"pass\">pass \u{2014} decline to act</div>");
} }
let _ = write!( let _ = write!(
@ -485,6 +501,34 @@ fn json_string(s: &str) -> String {
/// that parse: it drops markup and returns what a reader would see, plus /// that parse: it drops markup and returns what a reader would see, plus
/// the ids a pointer can address. A substring search over the raw source /// the ids a pointer can address. A substring search over the raw source
/// would happily find a token inside a comment or a style rule. /// would happily find a token inside a comment or a style rule.
/// Every `data-drop="…"` value in the document.
///
/// CB-WP-0016. A real parse of the attribute rather than a substring
/// search: `html.contains("seat-1")` would be satisfied by the *text*
/// "seat-1" and by `data-drop="seat-10"`, and the point of the check this
/// feeds is that an affordance can name a target that is not there.
///
/// **Drop keys are `data-drop`, not `id`, and that is the fix for
/// CB-WP-0016.** An `id` must be unique in a document, so exactly one
/// element could ever be `seat-0` — the relationship-graph circle took it
/// and the seat card the instruction text points at went without. A seat
/// is drawn twice and both drawings are the seat.
pub fn drop_keys(html: &str) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
let mut rest = html;
while let Some(i) = rest.find("data-drop=\"") {
let after = &rest[i + 11..];
match after.find('"') {
Some(j) => {
out.insert(after[..j].to_string());
rest = &after[j..];
}
None => break,
}
}
out
}
pub fn text_of(html: &str) -> String { pub fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2); let mut out = String::with_capacity(html.len() / 2);
let bytes: Vec<char> = html.chars().collect(); let bytes: Vec<char> = html.chars().collect();

View file

@ -67,8 +67,20 @@ function fetch(url, opts) {
var chainable = { then: function () { return chainable; } }; var chainable = { then: function () { return chainable; } };
return chainable; return chainable;
} }
function __down(id) { __handlers['pointerdown']({ target: { id: id } }); } // A node carrying one data-drop key, with a null parent — so the
function __up(id) { __handlers['pointerup']({ target: { id: id } }); } // 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
};
}
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) }); }
"#; "#;
/// Run the document's scripts, then a pointer gesture, and report what /// Run the document's scripts, then a pointer gesture, and report what
@ -276,6 +288,43 @@ mod tests {
assert!(e.contains("no pointer handlers"), "{e}"); assert!(e.contains("no pointer handlers"), "{e}");
} }
/// CB-WP-0016 T02. A drop on nothing must SAY so.
///
/// The old script returned without posting and without touching the
/// status line, so a broken drop target was indistinguishable from a
/// page that was working — which is exactly how the seat-card defect
/// 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();
ctx.add_callback("__post", move |_u: String, _b: String| {
*sink.lock().expect("posted") += 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");
}
ctx.eval("__down('action-attack'); __upNowhere();")
.expect("gesture");
// Nothing goes on the wire: there is no fact to report.
assert_eq!(*posted.lock().expect("posted"), 0);
// But the page does not go quiet about it.
let status: String = ctx
.eval_as("__status.textContent")
.expect("status readable");
assert!(
status.contains("action-attack") && status.contains("nothing droppable"),
"a drop on nothing said {status:?}"
);
}
#[test] #[test]
fn both_script_blocks_are_extracted_in_order() { fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page()); let found = scripts(&page());

View file

@ -331,5 +331,184 @@ mod coverage {
} }
} }
/// CB-WP-0016 T03: every affordance the page offers must name an element
/// the page actually contains.
///
/// **This is the check that closes the class the human check found.** On
/// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an
/// action onto a seat. Every test in the repo was green. The cause: the
/// visible seat cards carried no `id`, so `seat-{n}` existed only on the
/// 26 px circles inside the relationship graph, while every action card
/// read *"drag Attack onto a seat…"*.
///
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
/// into a synthetic `{target:{id}}` and never hit-tests, so it establishes
/// *"the script posts the ids it was given"* — never *"there is an element
/// there to give."* The 42-path coverage gate asserts each view field is
/// present in the parsed document, which a `<div>` with no id satisfies.
///
/// So: drive a real game, and at every decision point require that both
/// halves of every offered affordance appear as real `id` attributes.
#[cfg(test)]
mod affordances {
use std::cell::RefCell;
use std::rc::Rc;
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
use cb_kernel::PlayerId;
use games_ground::bot::{play, Choice, Policy, RandomPolicy};
use games_ground::{GroundCommand, GroundState};
use crate::{doc, input};
fn fresh(seed: u64) -> GroundState {
GroundState::setup(
&Setup {
players: 3,
preset: "standard-3p".into(),
patch: std::collections::BTreeMap::new(),
},
seed,
)
.expect("a standard 3p deal")
}
/// Renders the page at every real decision point and checks it, then
/// delegates the actual choice.
///
/// Hooking `Policy` rather than re-driving the game by hand matters:
/// these are the *same* decision points `cb-play --serve` renders at,
/// with the same `legal` list. A hand-rolled walk would be a second
/// implementation of the loop, and could agree with itself while
/// disagreeing with the thing shipped.
struct CheckingPolicy {
inner: RandomPolicy,
checked: Rc<RefCell<usize>>,
}
impl Policy for CheckingPolicy {
fn name(&self) -> &'static str {
"affordance-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);
let present = doc::drop_keys(&html);
for cmd in legal {
let Some((from, to)) = input::affordance(cmd, seat) else {
// A command with no affordance is offered through the
// numbered-button path instead. That is a stated
// shape, not a missing element.
continue;
};
assert!(
present.contains(&from),
"the page offers {cmd:?} whose GRAB id {from:?} is not an \
element in the document (seat {seat:?}, step {:?})",
state.step
);
assert!(
present.contains(&to),
"the page offers {cmd:?} whose DROP id {to:?} is not an \
element in the document (seat {seat:?}, step {:?}). \
Present ids: {present:?}",
state.step
);
}
*self.checked.borrow_mut() += 1;
self.inner.choose(state, seat, legal, may_pass)
}
}
/// **The check that closes the class the human check found.**
///
/// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not
/// drag an action onto a seat. Every test in the repo was green. The
/// cause: the visible seat cards carried no `id`, so `seat-{n}` existed
/// only on the 26 px circles inside the relationship graph, while every
/// action card read *"drag Attack onto a seat…"*.
///
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
/// into a synthetic `{target:{id}}` and never hit-tests, so it
/// establishes *"the script posts the ids it was given"* — never
/// *"there is an element there to give."* The coverage gate asserts
/// each view field appears in the parsed document, which a `<div>` with
/// no id satisfies perfectly.
///
/// An affordance naming an element that does not exist is the
/// harness-does-nothing shape in the presentation layer, and until now
/// it had no detector at all.
#[test]
fn every_offered_affordance_names_an_element_that_exists() {
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(CheckingPolicy {
inner: RandomPolicy::new(seed * 10 + i),
checked: checked.clone(),
}) as Box<dyn Policy>
})
.collect();
play(fresh(seed), &mut policies).expect("a bot game completes");
}
// Positive control: a run that rendered nothing would assert
// nothing and read as a pass — the exact failure this test exists
// to catch, one level up.
let n = *checked.borrow();
assert!(n >= 50, "checked only {n} decision point(s)");
}
/// **The regression test for the defect actually reported**, and the
/// reason the check above is not sufficient on its own.
///
/// `every_offered_affordance_names_an_element_that_exists` passes on
/// the broken tree. `seat-0` *did* exist — on the 26 px circle in the
/// relationship graph — so an existence check over the whole document
/// cannot see that the seat *card*, which is what the instruction text
/// points at, was not droppable.
///
/// A seat is drawn twice and both drawings are the seat. This asserts
/// the card specifically, by requiring the drop key on the element
/// that also carries `data-viewer` — the card, and nothing else.
#[test]
fn every_seat_card_is_a_drop_target_not_only_the_graph_node() {
let view = crate::testfix::view(Some(PlayerId(0)));
let html = doc::document(&view, &[], "/command?t=x", Some(PlayerId(0)), false);
for seat in view.players.keys() {
let card = format!(
"data-viewer=\"{}\" data-drop=\"seat-{}\"",
view.viewer == Some(*seat),
seat.0
);
assert!(
html.contains(&card),
"seat {seat:?} has a card that is not a drop target; looked for {card:?}"
);
}
// And the graph node keeps working — someone will have learned to
// aim at the circle, and this fix must not take that away.
let keys = doc::drop_keys(&html);
for seat in view.players.keys() {
assert!(keys.contains(&format!("seat-{}", seat.0)));
}
assert_eq!(
html.matches("data-drop=\"seat-0\"").count(),
2,
"seat 0 should be droppable in exactly two places: card and graph node"
);
}
}
#[cfg(test)] #[cfg(test)]
mod testfix; mod testfix;

View file

@ -0,0 +1,139 @@
# CB-EV-0014 — what the human check bought
CB-WP-0016 T03. Measured 2026-08-02 at `4df2d0a`+. Pass kind `product`,
tier **S** (chaos d4=3, no override). Declaration 11 of 12.
Cost quotes **CB-WP-0015's** figure, per CB-EV-0012's rule and with the
correction CB-EV-0013 §5 attached to it. See §5.
---
## 1. The check found a defect every test in the repo was blind to
CB-EV-0012 §4 kept INTENT stage 1 open on one action the loop could not
perform. The maintainer ran it. **The table reads. The drag did not work.**
Diagnosed against the live server *before* any code changed, which is what
made the rest cheap:
```
POST down=action-attack&up=seat-1 → ok (the game advanced)
POST down=action-attack&up=action-attack → "not a legal move here"
```
Socket, token guard, `resolve` and dispatch: all correct. The defect was in
the page, and it had a root cause worth more than the instance:
> **Drop targets were `id`s, and an `id` must be unique.** So exactly one
> element could ever be `seat-0`. The relationship-graph circle took it,
> and the seat *card* — which every action card's own text points at,
> *"drag Attack onto a seat…"* — silently had none.
A seat is drawn twice and both drawings are the seat. The document model
could not express that.
## 2. Why nothing caught it, stated precisely
| control | why it was blind |
|---|---|
| `jsrun::gesture` | calls `__down(id)`, which synthesized `{target:{id}}`. It feeds element ids straight in and **never hit-tests** — it establishes *"the script posts the ids it was given"*, never *"there is an element there to give"* |
| the 42-path coverage gate | asserts each view field is present in the **parsed document**. A `<div>` with no id satisfies that perfectly |
| `resolve`'s unit tests | test the mapping from a fact to a command. The fact never arrives |
| M-D1-MUT | its population is the AM-* acceptance rows. None of them is about the page |
Every one was green. This is the shape CB-WP-0015 closed one layer in — a
harness answering a narrower question than its name implies — recurring in
the presentation layer.
## 3. The fix, and the check that is honestly insufficient
**Drop keys are now `data-drop`, not `id`.** Any number of elements may
carry the same key, so a seat is droppable on its card *and* on its graph
node. Measured on the live page: `seat-0`, `seat-1`, `seat-2` each appear
**twice**; `id` survives on exactly one element, `cb-status`, which is the
only one the script looks up.
Two checks, and the difference between them is the finding:
| check | catches the reported defect? |
|---|---|
| **every offered affordance names a key that exists** — drives four real bot games through `Policy::choose`, renders at every real decision point | **NO.** `seat-0` *did* exist, on the graph circle |
| **every seat card is a drop target, not only the graph node** | **yes** |
The general check is worth having — it fails when a target is wholly
absent, which is a real class — but **it would not have found the bug the
maintainer found**, and saying otherwise would be the exact error this
project keeps catching. An existence check over a whole document cannot
tell you the element the user is being *pointed at* is the one that works.
Hooking `Policy::choose` rather than re-driving the game by hand matters:
those are the same decision points `cb-play --serve` renders at, with the
same `legal` list. A hand-rolled walk would be a second implementation of
the loop, free to agree with itself while disagreeing with what ships.
### Mutations, each red for its stated reason
| mutation | result |
|---|---|
| the seat card loses its drop key — *the reported defect, reintroduced* | red, **and only the targeted test fired**; the general one stayed green |
| the table stops being a drop target | red — *"whose DROP id `table` is not an element in the document"* |
| the silent `return` comes back | red — *"a drop on nothing said `""`"* |
## 4. Silence was the second defect
`SCRIPT` did `if (!down || !up) { down = null; return; }` — no POST, no
status line, nothing at all. **That is why a human sitting in front of it
could not tell a broken target from a working page.**
The Rust side already held the right principle: `resolve` refuses rather
than substituting a default, because *"a drag that means nothing must mean
nothing, not the first legal move."* Refusing is right. Refusing
**silently** is not, and the two had been conflated.
The page now reports the raw fact — *"took action-attack, let go over
nothing droppable"*. It names elements, not moves, so ADR-0007 control 5
is intact and the body-shape assertion still holds.
## 5. What the human check cost, and what it bought
Stage 1 was held open on this check for **two passes** (CB-WP-0014,
CB-WP-0015), against a standing temptation to close it on green tests —
CB-EV-0012 §4 recorded that temptation explicitly and refused it.
It bought a defect that made the stage's headline interaction
**non-functional on its primary target**, plus a root cause in the document
model, plus a control class that did not exist. Two passes of delay was the
right price, and the reasoning that kept it open — *"no test in this repo
can reach it"* — was exactly correct rather than merely cautious.
**The stage does not close here either.** The fix is verified by tests, by
mutation, and against a live server; it is **not** verified by a human
dragging. That is the same standard that found this, and the same one that
would have missed it.
| pass | kind | responses | cost | $/response |
|---|---|---|---|---|
| **CB-WP-0015** | product | 136 | **$15.14** | 0.111 |
| 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.
## 6. Open
- **INTENT stage 1: still one human verification**, now of a fix rather
than of an unknown. `cb-play --serve 0`, drag an action onto a seat
*card*.
- **The self-quoting rule still names the wrong boundary** — quote two
passes back. Owed since CB-EV-0013 §5.
- **AM-4b's scope defect (408,237 uncounted lines)** and its unmeasured
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** remains unratified after deciding two
dependency questions.
- **Chaos: 11 of 12 declarations, 1 override.** The calibration window
closes on the next declaration and owes an evaluation.

View file

@ -295,9 +295,13 @@ mod tests {
let replies = client.join().expect("client thread"); let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"), "{}", replies[0]); assert!(replies[0].contains("200 OK"), "{}", replies[0]);
assert!(replies[0].contains("GROUND"), "the page was not the table"); assert!(replies[0].contains("GROUND"), "the page was not the table");
// Through the parser, not a raw attribute match: this assertion
// read `id="action-ground"` and CB-WP-0016 moved drop keys to
// `data-drop`, so a substring test drifts silently on the next
// rename while still describing itself as checking the page.
assert!( assert!(
replies[0].contains("id=\"action-ground\""), cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"),
"the offered action was not on the page" "the offered action was not a drop target on the page"
); );
assert!(replies[1].contains("200 OK")); assert!(replies[1].contains("200 OK"));
assert!(server.refusals().is_empty()); assert!(server.refusals().is_empty());

View file

@ -2,7 +2,7 @@
id: CB-WP-0016 id: CB-WP-0016
kind: product kind: product
title: "The drop target that was never there" title: "The drop target that was never there"
status: todo status: done
--- ---
# Purpose # Purpose
@ -67,7 +67,7 @@ green tests two passes ago.
```task ```task
id: CB-WP-0016-T01 id: CB-WP-0016-T01
status: todo status: done
priority: high priority: high
``` ```
@ -84,11 +84,26 @@ inventing a new id vocabulary in the page is not.
must produce the same command, and a test must go red if either stops must produce the same command, and a test must go red if either stops
resolving. resolving.
**Done 2026-08-02.** The aliasing was not needed, because the constraint
that caused the defect was removed instead.
**Drop keys are now `data-drop`, not `id`.** An `id` must be unique, so
exactly one element could ever be `seat-0` — the graph circle took it and
the card went without. Any number of elements may carry the same
`data-drop`, so a seat is droppable on both of its drawings. Measured on
the live page: `seat-0/1/2` each appear **twice**, and `id` survives on
exactly one element, `cb-status`, which is the only one the script looks
up. `down=action-attack&up=seat-1` against a real server returns `ok`.
The mutation that reintroduces the reported defect — the seat card losing
its key — goes red, **and only the targeted test fires**; the general
existence check stays green. That is the point of T03's finding.
## Task: a gesture that lands nowhere must say so ## Task: a gesture that lands nowhere must say so
```task ```task
id: CB-WP-0016-T02 id: CB-WP-0016-T02
status: todo status: done
priority: high priority: high
``` ```
@ -105,11 +120,22 @@ Make the outcome visible. Keep ADR-0007 control 5 intact — whatever the
page reports must still be raw pointer facts with no game vocabulary, and page reports must still be raw pointer facts with no game vocabulary, and
the existing body-shape assertion must still hold. the existing body-shape assertion must still hold.
**Done 2026-08-02.** The page now writes *"took action-attack, let go over
nothing droppable"* to the status line. It names elements, not moves, so
control 5 holds and the body-shape assertion is untouched. Restoring the
bare `return` turns
`jsrun::tests::a_drop_on_nothing_reports_instead_of_going_quiet` red with
*"a drop on nothing said `\"\"`"*.
The JS DOM stub had to grow a real `getAttribute` and a null-parent node
to model a pointer landing on nothing — it previously could not express
the case at all, which is part of why the silence was invisible.
## Task: the check that closes the class ## Task: the check that closes the class
```task ```task
id: CB-WP-0016-T03 id: CB-WP-0016-T03
status: todo status: done
priority: high priority: high
``` ```
@ -134,3 +160,22 @@ Then `evidence/CB-EV-0014-*.md`:
say plainly whether the figure has settled. say plainly whether the figure has settled.
- **Chaos: declaration 11 of 12.** The calibration window closes next - **Chaos: declaration 11 of 12.** The calibration window closes next
declaration. declaration.
**Done 2026-08-02.**
[CB-EV-0014](../evidence/CB-EV-0014-the-drop-target.md). `make all`
exits 0.
- **The general check does not catch the reported defect, and the evidence
says so plainly.** `seat-0` existed — on the graph circle — so an
existence check over the whole document cannot tell that the element the
user is *pointed at* is not the one that works. It is kept because a
wholly absent target is a real class, and paired with a targeted
regression test that does catch it.
- **Three mutations, each red for its stated reason**, including the
reported defect reintroduced.
- **Stage 1 does not close here either.** The fix is verified by tests, by
mutation, and against a live server — not by a human dragging, which is
the standard that found it.
- **A cb-play assertion drifted**: it matched `id=\"action-ground\"` as a
substring and silently described itself as checking the page. Rewritten
through `doc::drop_keys`.