= offered.iter().map(|(_, d)| d.clone()).collect();
if !targets.is_empty() {
let _ = write!(
s,
"{a:?}
\
+ data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}
\
onto {names}
",
id = action_id(a),
targets = esc(&targets.join(" ")),
+ descs = esc(&descs.join("|")),
names = esc(&target_names(&targets)),
);
}
diff --git a/crates/cb-render-html/src/input.rs b/crates/cb-render-html/src/input.rs
index 8e2e604..6351740 100644
--- a/crates/cb-render-html/src/input.rs
+++ b/crates/cb-render-html/src/input.rs
@@ -96,6 +96,40 @@ pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<
}
}
+/// What a drop would mean, in a sentence.
+///
+/// **ADR-0010 Decision 1 puts this in Rust.** The page may render it; it
+/// may not compose it. A script that assembled "Attack P2" from an id and
+/// a seat name would be deriving a game fact, and would be wrong the
+/// moment a command meant something the ids do not say.
+pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String {
+ let who = |p: cb_kernel::PlayerId| format!("P{}", p.0 + 1);
+ match command {
+ GroundCommand::SelectAction {
+ action,
+ target,
+ problem,
+ } => {
+ let head = match action {
+ Action::Attack => "Attack",
+ Action::Support => "Support",
+ Action::Solve => "Solve",
+ Action::Investigate => "Investigate",
+ Action::Ground => "Ground",
+ };
+ match (target, problem) {
+ (Some(t), _) => format!("commit {head} against {}", who(*t)),
+ (_, Some(n)) => format!("commit {head} on problem {n}"),
+ _ => format!("commit {head}, untargeted"),
+ }
+ }
+ GroundCommand::SpendFreedom => {
+ format!("{} spends freedom to act again", who(seat))
+ }
+ other => format!("{other:?}"),
+ }
+}
+
/// Resolve a pointer fact to an index into the legal list.
///
/// Returns `Err` rather than a default when nothing matches: a drag that
diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs
index 5713ee3..4a84f60 100644
--- a/crates/cb-render-html/src/jsrun.rs
+++ b/crates/cb-render-html/src/jsrun.rs
@@ -64,13 +64,14 @@ var __all = [];
// A DOM node with a real classList and real attributes. CB-WP-0016 found
// that a stub too thin to express a failure is how the failure survives;
// the previous stub could not express class-toggling at all.
-function __mk(key, targets, text) {
+function __mk(key, targets, text, descs) {
var n = {
parentNode: null,
textContent: text || key,
style: {},
_cls: {},
- _attr: { 'data-drop': key, 'data-targets': targets || null },
+ _attr: { 'data-drop': key, 'data-targets': targets || null,
+ 'data-descs': descs || null },
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
setAttribute: function (a, v) { this._attr[a] = v; },
classList: {
@@ -81,7 +82,7 @@ function __mk(key, targets, text) {
};
return n;
}
-function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; }
+function __register(key, targets, descs) { var n = __mk(key, targets, key, descs); __all.push(n); return n; }
var document = {
body: __body,
@@ -103,7 +104,9 @@ function fetch(url, opts) {
// every one of them (CB-EV-0014 section 2).
function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); }
function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); }
-function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } }
+function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9, target: __mk(null, null, "") }); } }
+function __moveOver(k) { __handlers['pointermove']({ clientX: 5, clientY: 5, target: __find(k) }); }
+function __ghostText() { return __body.children.length ? __body.children[0].textContent : ""; }
function __cancel() { __handlers['pointercancel']({ target: __find(null) }); }
function __find(k) {
for (var i = 0; i < __all.length; i++) {
@@ -230,12 +233,29 @@ pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
Ok(())
}
+/// The `data-descs` of the element carrying `key`, if any.
+pub fn descriptions(html: &str, key: &str) -> Option {
+ let needle = format!("data-drop=\"{key}\"");
+ let i = html.find(&needle)?;
+ let tail = &html[i..];
+ let end = tail.find('>')?;
+ let k = tail[..end].find("data-descs=\"")?;
+ let t = &tail[k + 12..];
+ t.find('"').map(|e| t[..e].to_string())
+}
+
fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
for (key, targets) in droppables(html) {
- let call = match targets {
- Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)),
- None => format!("__register({}, null);", json_lit(&key)),
- };
+ // Descriptions are registered too: the stub could not express
+ // `data-descs` at all, so the first version of the description
+ // test failed against a DOM that simply did not carry them.
+ let descs = descriptions(html, &key);
+ let call = format!(
+ "__register({}, {}, {});",
+ json_lit(&key),
+ targets.map(|t| json_lit(&t)).unwrap_or("null".into()),
+ descs.map(|d| json_lit(&d)).unwrap_or("null".into()),
+ );
ctx.eval(&call)
.map_err(|e| format!("register {key}: {e}"))?;
}
@@ -274,6 +294,15 @@ mod tests {
target: Some(PlayerId(1)),
problem: None,
},
+ // TWO attack targets on purpose. With one, an off-by-one in
+ // the target/description pairing is a no-op and the mutations
+ // that should catch it survive — a fixture too thin to express
+ // the failure, which is how the failure survives (CB-EV-0014).
+ GroundCommand::SelectAction {
+ action: Action::Attack,
+ target: Some(PlayerId(2)),
+ problem: None,
+ },
];
crate::doc::document(
&crate::testfix::view(Some(PlayerId(0))),
@@ -536,6 +565,59 @@ mod tests {
}
}
+ /// **ADR-0010 D1 for descriptions.** Dragging over a legal target
+ /// shows the sentence Rust wrote **for that pair** — not one the page
+ /// assembled, and not a neighbouring pair's.
+ #[test]
+ fn dragging_over_a_target_shows_the_description_rust_wrote_for_it() {
+ let html = page();
+ let ctx = ctx_for(&html);
+
+ // What Rust wrote on the card, read back out of the document.
+ let card = droppables(&html)
+ .into_iter()
+ .find(|(k, _)| k == "action-attack")
+ .expect("the attack card");
+ let targets = card.1.expect("targets");
+ let descs = descriptions(&html, "action-attack").expect("descs");
+ let i = targets
+ .split(' ')
+ .position(|t| t == "seat-1")
+ .expect("seat-1 is a target");
+ let want = descs.split('|').nth(i).expect("a description").to_string();
+ assert!(!want.is_empty(), "the description is blank");
+
+ ctx.eval("__down('action-attack'); __moveOver('seat-1');")
+ .expect("drag over");
+ let shown: String = ctx.eval_as("__ghostText()").expect("ghost text");
+ assert_eq!(shown, want);
+
+ // Over nothing droppable it falls back to the label, rather than
+ // keeping a stale explanation pointing at the wrong element.
+ ctx.eval("__move();").expect("move away");
+ let away: String = ctx.eval_as("__ghostText()").expect("ghost text");
+ assert_ne!(away, want, "the explanation stuck after leaving the target");
+ }
+
+ /// Every advertised target has a description; a card offering three
+ /// drops and two sentences would silently mispair them.
+ #[test]
+ fn every_advertised_target_carries_its_own_description() {
+ let html = page();
+ for (key, targets) in droppables(&html) {
+ let Some(targets) = targets else { continue };
+ let descs = descriptions(&html, &key)
+ .unwrap_or_else(|| panic!("{key} advertises targets but no descriptions"));
+ assert_eq!(
+ targets.split(' ').count(),
+ descs.split('|').count(),
+ "{key}: {} target(s) but {} description(s)",
+ targets.split(' ').count(),
+ descs.split('|').count()
+ );
+ }
+ }
+
#[test]
fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page());
diff --git a/evidence/CB-EV-0016-the-browser-is-a-client.md b/evidence/CB-EV-0016-the-browser-is-a-client.md
new file mode 100644
index 0000000..d78a419
--- /dev/null
+++ b/evidence/CB-EV-0016-the-browser-is-a-client.md
@@ -0,0 +1,182 @@
+# CB-EV-0016 — the browser is a client, and window 1's verdict
+
+CB-WP-0018 T04. Measured 2026-08-03 at `e8bb726`+. Pass kind `product`,
+tier **M** (structural M — changes the loop's own constraints; chaos d4=3,
+no override). **Declaration 1 of chaos window 2**, opened by this pass.
+
+---
+
+## 1. A finished game was indistinguishable from a crash
+
+Reported: *"after some time i get an empty page back. I guess the game
+crashes or ends but that is unclear as the ui disappears."*
+
+Reproduced before touching anything, by driving a real game to completion
+over HTTP:
+
+```
+move 5 accepted → ok
+GET / → [Errno 111] Connection refused
+```
+
+The game had **ended normally** — 5 rounds, 30 commands — and its entire
+result went to a terminal nobody was reading. `next_choice` only accepts
+connections *inside* a human decision point, so when `play()` returned the
+listener died and the page's post-`ok` reload was refused.
+
+**The browser was a second-class client.** The outcome, the scores, the
+winners, and every error `run_game` can return were invisible to the only
+interface a player uses. Now: an 8,998-byte page reading *"GROUND — game
+over … 30 commands, hash f6c890a65271"*, with the final table and the log.
+
+It serves until the page posts `done` — the ending carries a *"close — I
+have read this"* control — with a 600 s linger, because a server that
+never exits is its own defect and a timeout would race a player reading
+the result.
+
+## 2. The control had to be built twice, and the first was worthless
+
+`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly. It
+passes. **Deleting the call from `run_game` left it green.**
+
+That is CB-EV-0012's finding recurring almost verbatim — *"every link was
+tested and the chain was not"* — and it survived one full round of
+mutation here before anyone noticed, because the mutation was run and the
+verdict was read as "no coverage gap" rather than "the test is in the
+wrong place".
+
+`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real
+`play()` with a browser seat, drives a real game to its end over a real
+socket, and requires the last page to be the ending. Under the same
+mutation it goes red — and its failure message prints an **empty page**,
+which is precisely the symptom that was reported.
+
+**A weak assertion of mine, caught by itself.** The first version grepped
+the ending page for `location.reload`. The page reuses `SCRIPT`, whose
+reload is guarded by `t.indexOf('ok') === 0`, and the ending endpoint
+answers `closed` — so the grep would have forced a second script into
+existence to satisfy a test rather than a requirement. That is the
+source-text control shape ADR-0010 D2 demoted three passes ago, reappearing
+in my own hands.
+
+## 3. The card report, and what it actually was
+
+Reported: *"the cards I play by pulling them on a target will not be
+removed … we will need a discard pile."*
+
+**A discard pile already existed** — `solution_discard` on `GroundState`,
+`SolutionDiscarded` removing the card from the hand, and the page already
+rendering `deck N remaining / discard …`. Building one would have been
+building a thing that was there, and the only reason that did not happen is
+that the code was read before the work started.
+
+What was being dragged are **action** cards. The five GROUND actions are
+not cards and are correctly never consumed; solution cards leave the hand
+at **Resolve**, because a selection is a face-down commit.
+
+But the report pointed at something real. Measured live:
+
+| move | hand | discard |
+|---|---|---|
+| 1 · Investigate → problem-2 | 2 cards | none |
+| 2 · Investigate → problem-3 | **3 cards** | none |
+| 3–5 · Solve → problem-1 | **4 cards, unchanged** | none |
+
+Investigate draws, correctly. **Solve was played three times and did
+nothing, three times, in silence** — GR-A02's resolver `continue`s when the
+problem is face-down, and `legal_commands` offers Solve on every face-up
+problem without consulting the hand.
+
+**Raised for `ground-game`, not decided here** (INTENT defers game
+semantics): *should SOLVE be selectable against a face-down problem, or
+against a suit the seat cannot match?* A face-down commit you cannot
+fulfil is a plausible bluff in a commit/reveal game with DARVO, which is
+exactly why it is not this repo's call.
+
+## 4. The log, and the limit of what it can honestly say
+
+`bot::Journal` — `Applied { actor, command, events }` appended by the
+driver through the new `play_journaled`; `play` delegates with `None`, so
+nothing existing changed. `BotGame.events` is the same information but only
+after `play` returns, which is no use to a page rendered mid-game.
+
+Phrased with `record::to_step`, so *what the player reads is what the
+scenario file will say*, and all 29 `GroundEvent` variants render in words
+rather than `{:?}`.
+
+**A command that produced no events says `no effect`**, and the mutation
+removing that branch goes red.
+
+**The honest limit:** the reported SOLVE case does *not* render as `no
+effect`, because the SOLVE resolves inside the system's `resolve` command,
+which does produce events for other seats. The player now sees the
+selection and sees no claim follow it — a large improvement on silence, but
+still an inference. Making it explicit would require the renderer to decide
+*why* a rule did nothing, which is a second implementation of the rules and
+is what this task's own control forbids. Left as an inference deliberately.
+
+## 5. The explanation, and two mutations that a thin fixture defeated
+
+Every advertised target now carries a sentence Rust wrote for **that pair**
+(`data-descs`, in step with `data-targets`), shown at the pointer while
+dragging over it. ADR-0010 D1 binds: the page renders it, never composes
+it.
+
+Both mutations — showing a neighbouring pair's text, and letting targets
+and descriptions fall out of step — **initially survived**, because the
+test fixture's Attack card had exactly **one** target, where an off-by-one
+shift and a truncation are both no-ops.
+
+That is CB-EV-0014's lesson again, one level in: *a stub too thin to
+express a failure is how the failure survives*. The fixture now offers two
+attack targets on purpose, and both mutations go red.
+
+## 6. Window 1's verdict, and a rate change on n=2
+
+Twelve declarations, two overrides, one each way, **both changed the
+outcome** — so window 1's retirement condition was not met and the
+mechanism is kept. The full table is in `InnerLoopReference.md` §Chaos
+roll.
+
+**Rate dropped d4 → d8; window 2 opened at 12 declarations; new retirement
+condition: retire if an override changes nothing twice running.**
+
+**The weakest part of this pass, stated plainly: it is a rate change argued
+from n=2.** The alternative — keep d4 for a second window and decide with
+four points — was live, and was rejected only because a quarter of all
+declarations is a large standing tax to pay for evidence. So window 2
+carries a falsifier: **if it produces no override at all, that is evidence
+the rate went too far**, not that the mechanism is healthy. A window that
+cannot fire cannot be evaluated, which is the exact failure d10 had.
+
+## 7. Cost
+
+| pass | kind | responses | cost | $/response |
+|---|---|---|---|---|
+| CB-WP-0016 | product | 64 | $14.93 | 0.233 |
+| **CB-WP-0017** | product | 40 | **$9.48** | 0.237 |
+| CB-WP-0018 | product | *provisional — not quoted* | | |
+
+Read by **re-running `make status` at the moment of writing**, which is
+CB-EV-0015 §6's correction applied for the first time: quoting a figure
+remembered from earlier in a session defeats the rule even when the
+boundary is right. CB-WP-0017 was reported at $5.19/23 mid-flight and
+settled at **$9.48/40** — 83% higher. **Six for six, always low.**
+
+**Meta budget 0% `[ok]`**, all three trailing passes product.
+
+## 8. Open
+
+- **INTENT stage 1: the human check.** Three runs, three defects no test
+ could reach. Everything in this pass is verified by tests, mutation and a
+ live socket; nothing perceptual is.
+- **For `ground-game`:** should SOLVE be selectable against a face-down
+ problem or an unmatchable suit? §3.
+- **The self-quoting rule** now has both halves recorded but is still not
+ written into the loop spec.
+- **AM-4b's scope defect (408,237 uncounted lines)** and its proc-macro
+ share.
+- **`python3` as a toolchain dependency was never argued.**
+- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000.
+- **ADR-0007 D3's acquisition rule** and **D5** remain unratified;
+ ADR-0010 rests on the latter.
diff --git a/gates.toml b/gates.toml
index 680c6d4..9fde325 100644
--- a/gates.toml
+++ b/gates.toml
@@ -120,15 +120,15 @@ retire_if = "two passes run with no finding while artifacts keep growing — tha
id = "CHAOS"
name = "the chaos roll"
target = ""
-checks = "d4 on each tier declaration, 12-declaration calibration window"
+checks = "d8 on each tier declaration, 12-declaration calibration window (window 2, opened 2026-08-03; window 1 ran at d4)"
added = "2026-07-30"
-review_by = "2026-09-30"
+review_by = "2026-11-30"
caught = [
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
"CB-WP-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it",
"CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)",
]
-retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible"
+retire_if = "an override changes nothing twice running (window 2 condition, CB-WP-0018 T04). Window 1's condition — no override changing the outcome — was NOT met: both did, so the mechanism was kept and the rate dropped d4 → d8 instead"
# VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides).
# Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a
# defect in the existing renderer that the deleted survey would have walked
diff --git a/specs/InnerLoop.md b/specs/InnerLoop.md
index c41965c..1de6699 100644
--- a/specs/InnerLoop.md
+++ b/specs/InnerLoop.md
@@ -120,17 +120,22 @@ are never skipped for code-producing work.
| **M** | Survey and ADR merged into one document; review optional | Touches a canonical interface, adds/updates an external dependency, **or changes whether or how the loop constrains its own operation** — budgets, gates, review requirements, or these tier rules (v1.6, ADR-0006 D5) |
| **S** | One provenance paragraph in the commit message | Everything else (utilities, fixes, refactors inside a boundary) |
-**The chaos roll.** After deriving the structural tier, roll **d4**
-(`shuf -i 1-4 -n 1`). On a **4**, the tier is instead picked uniformly at
+**The chaos roll.** After deriving the structural tier, roll **d8**
+(`shuf -i 1-8 -n 1`). On an **8**, the tier is instead picked uniformly at
random (`shuf -e S M L -n 1`), overriding the structural derivation — up or
down.
-> **Calibration window, opened 2026-07-31, running to 12 tier
-> declarations** (declaration 4 of 12 as of 2026-08-01). The rate was
-> raised from d10 to d4 because at d10 the mechanism never fired and
-> so prevented its own evaluation. Record the roll every time,
-> including when it changes nothing (`tier: L (structural L, chaos 4)`).
-> Rationale, cost estimate and the two dead rolls:
+> **Window 1 closed 2026-08-02** at 12 declarations, 2 overrides, one each
+> way, and **both changed the outcome** — so the mechanism was kept and
+> the rate dropped d4 → d8 (CB-EV-0015 §5, CB-EV-0016 §4).
+>
+> **Window 2, opened 2026-08-03 at d8**, running to 12 declarations.
+> Retirement condition: **retire if an override changes nothing twice
+> running.**
+>
+> Record the roll every time, including when it changes nothing
+> (`tier: L (structural L, chaos 8)`). Why the rate fell, why n=2 makes
+> that the weakest part of the decision, and the dead rolls:
> `specs/InnerLoopReference.md` §Chaos roll — calibration.
Chaos limits: a rolled-down tier relaxes *process* weight only. Invariants
diff --git a/specs/InnerLoopReference.md b/specs/InnerLoopReference.md
index bf2b529..e60f773 100644
--- a/specs/InnerLoopReference.md
+++ b/specs/InnerLoopReference.md
@@ -195,3 +195,35 @@ four implementation rules the pass earned, and the requirement that
evidence state what it does not support. Rationale and the failures behind
each: `history/260731-inner-loop-retrospective.md`.
+
+## Chaos roll — window 1's verdict and the d4 → d8 change
+
+*(CB-WP-0018 T04, 2026-08-03. Full argument in `evidence/CB-EV-0015.md` §5
+and `evidence/CB-EV-0016.md` §4.)*
+
+Window 1 ran 2026-07-31 → 2026-08-02, twelve declarations, at d4 after an
+earlier d10 that never fired and so prevented its own evaluation.
+
+**Two overrides, one in each direction, and both changed the outcome**, so
+window 1's retirement condition — *"the window closes with no overridden
+tier producing a different outcome than the argued one"* — was not met:
+
+| pass | roll | what the override bought |
+|---|---|---|
+| CB-WP-0011 | structural L → **S** | the deleted survey would have opened on 2D toolkits; the pass instead found the existing text renderer showing 24 of 41 view fields. Priced on the same subject: 0.099 $/response at S against 0.123 at L |
+| CB-WP-0017 | structural S → **M** | ADR-0010. At tier S the page's script would have grown from *"it does one thing"* to holding a drag, following the pointer and marking other elements under a one-paragraph commit note, silently outgrowing ADR-0007 D5 |
+
+**Why the rate fell.** Both were informative *because they were rare*. At
+d4 the mechanism overrides a quarter of all declarations, at which point it
+stops being a calibration on the tier table and becomes a second tier
+table. d8 keeps the mechanism and restores its rarity.
+
+**The weakest part of this decision, stated plainly:** it is a rate change
+argued from **n=2**. The alternative — keep d4 for a second window and
+decide with four data points — was live and was rejected only because a
+quarter of declarations is a large standing tax to pay for evidence.
+
+So window 2 carries a falsifier: **if it produces no override at all, that
+is evidence the rate went too far**, not evidence the mechanism is
+healthy. A window that cannot fire cannot be evaluated, which is the exact
+failure d10 had.
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index a0659bb..083c32f 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -19,7 +19,7 @@ use std::rc::Rc;
use cb_game_runtime::{Project, Viewer};
use cb_kernel::PlayerId;
-use cb_render_html::{document, resolve, Guard, PointerFact, Request};
+use cb_render_html::{resolve, Guard, PointerFact, Request};
use games_ground::bot::{Choice, Policy};
use games_ground::{GroundCommand, GroundState};
diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md
index 0955fed..e125086 100644
--- a/workplans/CB-WP-0018-the-browser-is-a-client.md
+++ b/workplans/CB-WP-0018-the-browser-is-a-client.md
@@ -2,7 +2,7 @@
id: CB-WP-0018
kind: product
title: "The browser is a client: game over, a log, and where a drop goes"
-status: todo
+status: done
---
# Purpose
@@ -235,7 +235,7 @@ the question of whether the move should be offered at all.
```task
id: CB-WP-0018-T03
-status: todo
+status: done
priority: high
```
@@ -257,11 +257,23 @@ description a target shows is the one Rust wrote for *that* pair — a
mutation that shows a neighbouring pair's text must go red. The set
equality property from ADR-0010 D2 must still hold.
+**Done 2026-08-03.** `input::describe` writes a sentence per legal
+command; `data-descs` carries them in step with `data-targets`; the ghost
+already following the pointer shows the one for whatever legal target is
+under it, so the explanation appears beside the target without an overlay
+layer to keep aligned.
+
+**Both mutations initially SURVIVED**, because the fixture's Attack card
+had exactly **one** target — where an off-by-one shift and a truncation
+are both no-ops. That is CB-EV-0014's lesson one level in: *a fixture too
+thin to express a failure is how the failure survives*. The fixture now
+offers two attack targets on purpose and both go red.
+
## Task: close the chaos change, and the evidence
```task
id: CB-WP-0018-T04
-status: todo
+status: done
priority: high
```
@@ -293,3 +305,21 @@ Then `evidence/CB-EV-0016-*.md`:
memory — CB-EV-0015 §6 found that quoting a remembered figure defeats
the rule even when the boundary is right, and this is the first pass
that can apply that correction.
+
+**Done 2026-08-03.**
+[CB-EV-0016](../evidence/CB-EV-0016-the-browser-is-a-client.md). `make
+all` exits 0.
+
+- **Chaos rate d4 → d8, window 2 open at 12 declarations**, retiring if an
+ override changes nothing twice running. Window 1's condition was not
+ met — both overrides changed the outcome — so the mechanism is kept.
+ **The weakest part of the decision is that it is a rate change argued
+ from n=2**, and window 2 therefore carries a falsifier: no override at
+ all is evidence the rate went too far.
+- The rationale moved to `InnerLoopReference.md` because `InnerLoop.md`
+ hit 401 lines and the loadability gate fired — fixed structurally, per
+ the standing precedent that limits are not raised.
+- **CB-WP-0017 settled at $9.48/40** against $5.19/23 reported mid-flight,
+ 83% higher. Six for six, always low. Read by re-running the instrument
+ at the moment of quoting, which is CB-EV-0015 §6's correction applied
+ for the first time.