Compare commits

..

2 commits

Author SHA1 Message Date
bf72a1863a 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>
2026-08-02 20:48:18 +02:00
4df2d0a4ae Declare CB-WP-0016: the drop target that was never there, tier S
Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check CB-EV-0012 kept stage 1 open on was run by the maintainer and
found the drag broken. Diagnosed against the live server first:
down=action-attack&up=seat-1 returns ok, so socket, guard, resolve and
dispatch are correct. seat-{n} ids exist only on the SVG circles in the
relationship graph; player_card emits the visible seat cards with no id,
so the target every action card names is inert. jsrun feeds element ids
straight in and never hit-tests, which is why every test passed.
Structural tier S: a defect fix inside an existing capability, and the
check it adds is a product test rather than a control gate. Chaos d4=3,
no override. Declaration 11 of 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:35:18 +02:00
9 changed files with 628 additions and 23 deletions

1
Cargo.lock generated
View file

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

View file

@ -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,

View file

@ -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]

View file

@ -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();

View file

@ -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());

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)]
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");
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());

View file

@ -0,0 +1,181 @@
---
id: CB-WP-0016
kind: product
title: "The drop target that was never there"
status: done
---
# Purpose
```
structural tier S (a defect fix inside an existing capability — no new
port, no canonical interface, no new dependency, and
the check it adds is a product test, not a control
gate)
chaos d4 = 3 → no override
declared tier S
```
Declaration 11 of 12. Tier S: one provenance paragraph in the commit
message, no survey and no ADR. Steps 45 are not skipped.
## Found by the human check, and by nothing else
CB-EV-0012 §4 kept INTENT stage 1 open on one action the loop could not
perform: run `cb-play --serve 0`, open the URL, confirm the table reads and
a drag works. The maintainer ran it on 2026-08-02. **The table reads. The
drag does not.**
Diagnosed against the running server before any code was changed:
```
POST down=action-attack&up=seat-1 → ok (the game advanced)
POST down=action-attack&up=action-attack → "not a legal move here"
```
So the socket, the token guard, `resolve`, and command dispatch are all
correct. The defect is in the emitted page.
**`seat-{n}` ids exist only on the ~26 px circles inside the relationship
graph** (`<g id="seat-0">`, `doc.rs:185`). The visible *seats* section
cards are emitted by `player_card` as `<div class="card" data-viewer=…>`
with **no id**. Every action card says *"drag Attack onto a seat, a
problem, or the table"*, and the seat it points at is not a target.
`#table`, `problem-{n}` and `freedom-{n}` are real targets, so the page is
not wholly inert — the most natural target is.
## Why every test in the repo passed
`jsrun::gesture(html, down, up)` calls `__down(id)`, which synthesizes
`{ target: { id: id } }`. **It feeds element ids straight in and never
hit-tests.** So it establishes *"the script posts the ids it was given"*
and never *"the page has an identified element where the instruction says
to drop."*
The 42-path coverage gate is blind for a related reason: it asserts each
view field is **present in the parsed document**, which a `<div>` with no
id satisfies perfectly.
CB-WP-0015 closed a clause whose test was named for a property it did not
check. This is the same shape one layer out — a harness answering a
narrower question than its name implies — and it is the argument for
having kept the human check as a blocker rather than closing stage 1 on
green tests two passes ago.
## Task: make the seat a drop target, and keep ids unique
```task
id: CB-WP-0016-T01
status: done
priority: high
```
The seat card must carry the id `affordance()` already names for it.
`seat-{n}` is currently taken by the SVG node, and duplicate ids are
invalid HTML — so one of the two has to change, and **both must remain
droppable.** The graph circle works today and someone will have learned it.
Decide where the aliasing lives. Rust decides what a pointer fact means
(ADR-0007 D5), so widening what `resolve` accepts is in bounds and
inventing a new id vocabulary in the page is not.
**Control:** dropping on the seat *card* and dropping on the graph *node*
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: done
priority: high
```
`SCRIPT` currently does `if (!down || !up) { down = null; return; }` — no
POST, no status line, nothing. That silence is why this defect survived a
human sitting in front of it.
The Rust side already holds 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 correct. Refusing
**silently** is not.
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: done
priority: high
```
**For every legal command, both ids `affordance()` produces must occur in
the emitted document.** Pure Rust, no JS engine, no browser, deterministic.
It fails on today's tree and it would have caught this the day the
renderer was written.
This is the deliverable that matters — T01 fixes one instance, this closes
the class. An affordance naming an element that does not exist is exactly
the harness-does-nothing shape, and it currently has no detector at all.
Then `evidence/CB-EV-0014-*.md`:
- **What the human check bought**, priced honestly against what it cost to
keep stage 1 open for two passes.
- **Whether stage 1 closes now.** It needs the maintainer to drag again;
say so rather than closing it on a passing test, which is the failure
mode this whole pass is evidence for.
- **Quote CB-WP-0015's final cost, not this pass's** — and note that
CB-EV-0013 §5 found the self-quoting rule fixes the wrong boundary, so
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`.