CB-WP-0016: the drop target that was never there
Some checks failed
ci / check (push) Failing after 4s
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:
parent
4df2d0a4ae
commit
bf72a1863a
9 changed files with 496 additions and 27 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -91,6 +91,7 @@ dependencies = [
|
|||
name = "cb-render-html"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cb-game-runtime",
|
||||
"cb-kernel",
|
||||
"games-ground",
|
||||
"quick-js",
|
||||
|
|
|
|||
13
INTENT.md
13
INTENT.md
|
|
@ -64,11 +64,14 @@ game.
|
|||
and scenario tests, simple bots. No rendering, no physics.
|
||||
1. **Inspectable 2D table** — card/token/hand/relationship-graph
|
||||
visualization, drag-to-propose, debug inspector, hot-seat play.
|
||||
*Open on one human verification (CB-EV-0012 §4): the inspector,
|
||||
drag-to-propose and hot-seat play are evidenced by executing code;
|
||||
the visualization is evidenced only as correctly emitted, because no
|
||||
browser is available to the loop. Run `cb-play --serve 0`, open the
|
||||
printed URL, and confirm the table reads and a drag works.*
|
||||
*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.*
|
||||
2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera
|
||||
and pointer controls, snap zones, asset importer.
|
||||
3. **Networked sessions** — authoritative host, private projections,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ quick-js = { version = "0.4", optional = true }
|
|||
[dev-dependencies]
|
||||
# The coverage gate walks the serialized view; nothing else needs it.
|
||||
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"
|
||||
|
||||
[lints]
|
||||
|
|
|
|||
|
|
@ -57,15 +57,25 @@ fn cards(list: &[games_ground::SolutionCard]) -> String {
|
|||
pub const SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var down = null;
|
||||
function id(e) {
|
||||
function key(e) {
|
||||
var n = e.target;
|
||||
while (n && !n.id) { n = n.parentNode; }
|
||||
return n ? n.id : null;
|
||||
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
|
||||
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) {
|
||||
var up = id(e);
|
||||
if (!down || !up) { down = null; return; }
|
||||
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;
|
||||
return;
|
||||
}
|
||||
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
|
||||
down = null;
|
||||
fetch(window.CB_ENDPOINT, {
|
||||
|
|
@ -122,7 +132,7 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
|
|||
};
|
||||
let _ = write!(
|
||||
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\"/>\
|
||||
<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>\
|
||||
|
|
@ -182,7 +192,7 @@ fn relations_svg(view: &GroundView) -> String {
|
|||
.map(|f| format!(" \u{2192}{}", seat_name(*f)));
|
||||
let _ = write!(
|
||||
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\"/>\
|
||||
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
|
||||
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 _ = write!(
|
||||
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),
|
||||
you = if is_viewer { " (you)" } else { "" },
|
||||
);
|
||||
|
|
@ -216,7 +232,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
|
|||
);
|
||||
let _ = write!(
|
||||
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>",
|
||||
raw = id.0,
|
||||
ready = if p.freedom_ready { "READY" } else { "spent" },
|
||||
|
|
@ -422,7 +438,7 @@ pub fn document(
|
|||
if offered {
|
||||
let _ = write!(
|
||||
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>",
|
||||
id = action_id(a),
|
||||
);
|
||||
|
|
@ -434,18 +450,18 @@ pub fn document(
|
|||
if !spatial {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div class=\"card btn\" id=\"cmd-{i}\">{}</div>",
|
||||
"<div class=\"card btn\" data-drop=\"cmd-{i}\">{}</div>",
|
||||
esc(&format!("{c:?}"))
|
||||
);
|
||||
}
|
||||
}
|
||||
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>",
|
||||
);
|
||||
}
|
||||
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!(
|
||||
|
|
@ -485,6 +501,34 @@ fn json_string(s: &str) -> String {
|
|||
/// that parse: it drops markup and returns what a reader would see, plus
|
||||
/// the ids a pointer can address. A substring search over the raw source
|
||||
/// would happily find a token inside a comment or a style rule.
|
||||
/// 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 {
|
||||
let mut out = String::with_capacity(html.len() / 2);
|
||||
let bytes: Vec<char> = html.chars().collect();
|
||||
|
|
|
|||
|
|
@ -67,8 +67,20 @@ function fetch(url, opts) {
|
|||
var chainable = { then: function () { return chainable; } };
|
||||
return chainable;
|
||||
}
|
||||
function __down(id) { __handlers['pointerdown']({ target: { id: id } }); }
|
||||
function __up(id) { __handlers['pointerup']({ target: { id: id } }); }
|
||||
// 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
|
||||
};
|
||||
}
|
||||
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
|
||||
|
|
@ -276,6 +288,43 @@ mod tests {
|
|||
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]
|
||||
fn both_script_blocks_are_extracted_in_order() {
|
||||
let found = scripts(&page());
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
mod testfix;
|
||||
|
|
|
|||
139
evidence/CB-EV-0014-the-drop-target.md
Normal file
139
evidence/CB-EV-0014-the-drop-target.md
Normal 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.
|
||||
|
|
@ -295,9 +295,13 @@ mod tests {
|
|||
let replies = client.join().expect("client thread");
|
||||
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
|
||||
assert!(replies[0].contains("GROUND"), "the page was not the table");
|
||||
// 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!(
|
||||
replies[0].contains("id=\"action-ground\""),
|
||||
"the offered action was not on the page"
|
||||
cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"),
|
||||
"the offered action was not a drop target on the page"
|
||||
);
|
||||
assert!(replies[1].contains("200 OK"));
|
||||
assert!(server.refusals().is_empty());
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
id: CB-WP-0016
|
||||
kind: product
|
||||
title: "The drop target that was never there"
|
||||
status: todo
|
||||
status: done
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
|
@ -67,7 +67,7 @@ green tests two passes ago.
|
|||
|
||||
```task
|
||||
id: CB-WP-0016-T01
|
||||
status: todo
|
||||
status: done
|
||||
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
|
||||
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
|
||||
id: CB-WP-0016-T02
|
||||
status: todo
|
||||
status: done
|
||||
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
|
||||
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
|
||||
id: CB-WP-0016-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -134,3 +160,22 @@ Then `evidence/CB-EV-0014-*.md`:
|
|||
say plainly whether the figure has settled.
|
||||
- **Chaos: declaration 11 of 12.** The calibration window closes next
|
||||
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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue