Compare commits

...

2 commits

Author SHA1 Message Date
7fa395e377 CB-WP-0027 T05: evidence — and the one question this file cannot answer
Some checks failed
ci / check (push) Has been cancelled
CB-EV-0025. T05 asks whether a comment actually got written during play,
unprompted. It has not been, and that is the pass's real acceptance test.
Everything built here is evidence the mechanism works; none of it is
evidence it gets used. I exercised it myself, which proves the plumbing
and proves nothing about the ergonomics -- the person who wrote the
feature is the worst possible witness to whether it is worth using.

Stated plainly rather than answered optimistically, because CB-WP-0022
built a register whose first run found ten answers nobody had collected:
this project's failures are about whether things get READ.

ADR-0007 D5 turned out to be the easy part. PointerFact::parse already
refuses any unrecognised field, so a comment could not reach the command
path even by accident. That is strictness at a boundary paying off years
early, and the clearest instance in this project of it being cheaper than
vigilance across one.

esc() held against its first hostile input, and the test asserts the
harder half: the player's words are still READABLE after escaping. An
escaper that dropped the text would pass "no script tag in the output"
while destroying the note.

The reflow cost no coverage probe -- all 41 pre-existing render tests
passed through a full restructure into two columns. CB-WP-0024 saw the
opposite when a probe was tied to a rendering ("17 remaining"), and the
contrast is the useful part: a probe that names a FACT survives a reflow,
a probe that names a PRESENTATION does not.

Running it changed the design for the third pass running, after
CB-WP-0024's role column and CB-WP-0025's K=2 infeasibility. The pattern
is consistent enough to state: the ADRs in this project are good at
deciding and poor at predicting, and the loop's value comes
disproportionately from the code loop rather than from steps 1-3.

Chaos window 2 closes with zero overrides in twelve declarations, so its
retirement condition is untestable -- final now rather than projected. The
window's verdict should be that d8 made the mechanism unevaluable.

Also recorded rather than dismissed: make all failed once at env-test and
passed on re-run. A cb-play server and several cargo processes were
running concurrently, which is the likely cause -- but "likely" is doing
work in that sentence and I did not chase it. A gate that fails
intermittently and is re-run until green is a gate being trained not to
matter.

Not built, and named: promoting a comment to a register note is still
manual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:39:12 +02:00
4fb506fcd1 CB-WP-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot
carry a move.

T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was
easy is that PointerFact::parse already refuses any unrecognised field --
a comment could not reach the command path even by accident. So /command
carries pointer facts, /note carries text, and Note has no code path to
GroundCommand. Comments live in trials/<date>-<slug>.md, not in
ScenarioFile: a scenario is executed, replayed and hashed, and prose in it
is data the runner must ignore, which is how a format rots. The state hash
binds; round and step are for reading. And the retention question, decided
before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note
reaches ground-game only by being promoted to a register finding, by a
human, with the wording chosen then -- "the DARVO sequence is infuriating"
is useful signal and a bad way to open a message to the game's designer.

T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a
grid child defaults to min-content width and without it the SVG table
refuses to shrink and pushes the meta column off-screen, looking correct
on the developer's monitor and broken everywhere else. Single-column
fallback under 64rem. The running tally moved into the panel so it is
visible WHILE PLAYING; it only appeared on the ending page before, and a
score you see once the game is over informs nothing.

T03. A plain <form method="post">, so the box works with the script
disabled; the command channel needs JavaScript because a drag is not a
form submission, a comment is one. 303 See Other so a reload does not
re-post. esc()'s first hostile input: <script>alert(1)</script> renders
escaped AND STILL READABLE -- escaping that eats the player's words is its
own defect. Verified over real HTTP: note posted 303, hostile note stored
as text, empty note refused 400, game did not advance.

T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE
I RAN IT: the first version called any note without a recording an orphan,
so a live session reported every note as broken -- the recording is only
written at game end. A metric that cries wolf is one nobody reads, which
is the exact failure this pass exists to prevent. Now ok / pending /
orphan, and only orphan is a target-0 number. The self-test exercises the
REPORTING path, not just the parser, because design-baseline.py had a
green self-test and an unexercised reporting path and that is where it
rotted.

And a latent Makefile defect surfaced: make trials did nothing, because
trials is also a directory and Make saw an up-to-date file. design,
difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass --
were ALL missing from .PHONY; only the one that collided revealed it.

make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:37:46 +02:00
12 changed files with 1189 additions and 11 deletions

View file

@ -24,7 +24,13 @@ TOOLS := $(REPO)/tools
# Every cargo recipe runs at the repo root; the shell does not persist cd.
IN_REPO := cd $(REPO) &&
.PHONY: check test sim bench bench-test coverage dep-weight cost cost-test cost-pin cost-budget shape-budget cost-mix loop-lint self-tests env-test task-done status facts-check facts-gen mutation-check size-metrics runtime-metrics build-time am6 am7 am8 edition-check replay-test loc play gate-review all
.PHONY: check test sim bench bench-test coverage dep-weight cost cost-test cost-pin cost-budget shape-budget cost-mix loop-lint self-tests env-test task-done status facts-check facts-gen mutation-check size-metrics runtime-metrics build-time am6 am7 am8 edition-check replay-test loc play gate-review all \
design difficulty trials
# `design`, `difficulty` and `trials` were added by CB-WP-0022, CB-WP-0025
# and CB-WP-0027 and none was declared here. Only `trials` revealed it, by
# colliding with the trials/ DIRECTORY -- Make saw an up-to-date file and
# ran nothing. The other two work by luck: no file happens to share their
# name. A target that is a command, not a file, belongs on this line.
## fmt + clippy (deny warnings) + HashMap deny-lint
check:
@ -125,6 +131,7 @@ self-tests:
$(PY) $(TOOLS)/runtime-metrics.py --self-test
$(PY) $(TOOLS)/replay-test.py --self-test
$(PY) $(TOOLS)/design.py --self-test
$(PY) $(TOOLS)/trials.py --self-test
cargo run --release -q -p games-ground --example difficulty -- --self-test
$(PY) $(TOOLS)/edition-check.py --self-test
@ -161,6 +168,12 @@ facts-check:
facts-gen:
$(PY) $(TOOLS)/facts.py --gen
# CB-WP-0027 T04: what the players said, and where. Surfacing is the
# deliverable -- a commentary feature nobody can read is this project's
# signature failure in a new medium (ADR-0014 D6).
trials:
@$(PY) $(TOOLS)/trials.py
# CB-WP-0025 T06: the difficulty table (specs/RetrospectiveAnalysis.md §4).
# Winnable fraction from the solver plus a PLURAL policy panel -- a single
# policy's win rate may not be reported as a difficulty (§4.1).

View file

@ -255,6 +255,25 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
styling alone would leave a dead control that still looks alive to
anything reading the DOM. */
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
/* CB-WP-0027 T02. `minmax(0,...)` on both tracks, because a grid child
defaults to min-content width and the SVG table would refuse to shrink,
pushing the meta column off-screen instead of narrowing.
The single-column fallback is deliberate rather than incidental: the
page was responsive by accident before this. */
.cb-cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,24rem);
gap:1.2rem;align-items:start}
.cb-game{min-width:0}
.cb-meta{min-width:0;border-left:1px solid #2a3140;padding-left:1.1rem}
#cb-note{display:flex;flex-direction:column;gap:.5rem}
#cb-note textarea{width:100%;box-sizing:border-box;font:inherit;color:#dde;
background:#12141a;border:1px solid #445;border-radius:5px;padding:.5rem}
#cb-note button{align-self:flex-start;font:inherit;cursor:pointer;color:#dde;
background:#332a3a;border:1px solid #a7d;border-radius:5px;padding:.35rem .8rem}
.note{border-left:2px solid #a7d;padding-left:.6rem;margin:.4rem 0;white-space:pre-wrap}
@media (max-width:64rem){
.cb-cols{grid-template-columns:minmax(0,1fr)}
.cb-meta{border-left:none;border-top:1px solid #2a3140;padding-left:0;padding-top:1rem}
}
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
.played{display:block;margin:.3rem 0}
.k{color:#89a}
@ -679,7 +698,7 @@ pub fn document(
seat: Option<PlayerId>,
may_pass: bool,
) -> String {
document_with_log(view, legal, endpoint, seat, may_pass, &[])
document_with_log(view, legal, endpoint, seat, may_pass, &[], &[])
}
/// The table, plus the game log (CB-WP-0018 T02).
@ -690,6 +709,7 @@ pub fn document_with_log(
seat: Option<PlayerId>,
may_pass: bool,
log: &[LogLine],
meta: &[String],
) -> String {
let mut s = String::with_capacity(8192);
let _ = write!(
@ -716,9 +736,16 @@ pub fn document_with_log(
},
);
// CB-WP-0027 T02: the table on the left, everything *about* the table
// on the right. The log moves right because it is commentary on the
// game rather than part of it.
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
body(&mut s, view);
move_section(&mut s, legal, seat, may_pass);
s.push_str("</div><div class=\"cb-meta\">");
meta_section(&mut s, meta);
log_section(&mut s, log);
s.push_str("</div></div>");
let _ = write!(
s,
"<div id=\"cb-status\">ready</div>\
@ -729,6 +756,40 @@ pub fn document_with_log(
s
}
/// The meta panel's own content: whatever the caller wants a player to see
/// *about* the session rather than about the position.
///
/// Empty is a legitimate state — a first game has no tally and may have no
/// notes — and renders as nothing rather than as an empty heading.
fn meta_section(s: &mut String, meta: &[String]) {
// CB-WP-0027 T03: the comment box. Always present — the panel's
// purpose is that a player can say something at any moment, and a box
// that appears only sometimes trains them not to look for it.
//
// A plain form POSTing to /note, so it works with the script disabled.
// The command channel needs JavaScript because a drag is not a form
// submission; a comment is, and making it depend on the script would
// add a failure mode for no gain.
s.push_str(
"<h2>what are you thinking?</h2>\
<form class=\"card\" method=\"post\" action=\"/note\" id=\"cb-note\">\
<textarea name=\"note\" rows=\"4\" placeholder=\"why this move, what is \
unclear, what is annoying \u{2014} bound to this position\"></textarea>\
<button type=\"submit\">note it</button></form>",
);
if meta.is_empty() {
return;
}
s.push_str("<h2>this session</h2><div class=\"card\">");
for (i, line) in meta.iter().enumerate() {
if i > 0 {
s.push_str("<br>");
}
s.push_str(&esc(line));
}
s.push_str("</div>");
}
/// The table itself: problems, relationships, seats, solutions, outcome.
///
/// Factored out of [`document`] so [`ending`] shows the SAME table rather

View file

@ -56,6 +56,91 @@ impl PointerFact {
}
}
/// What a test player wrote, and where they wrote it (CB-WP-0027,
/// ADR-0014 D1).
///
/// **A second channel that cannot carry a move.** ADR-0007 D5 governs the
/// *command* channel and is untouched: [`PointerFact`] still admits two
/// ids and refuses every other field. This type carries text, and there is
/// **no code path from it to a `GroundCommand`** — [`crate::resolve`]
/// takes a `PointerFact` and nothing else, so a note cannot become a move
/// by any route, including a future careless one.
///
/// If this struct ever grows a field the engine reads, that separation is
/// gone and ADR-0007 says to revisit the decision rather than widen the
/// control.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Note {
/// The player's own words, verbatim. Never interpreted, only stored
/// and escaped on render.
pub text: String,
}
impl Note {
/// Parse the wire form `note=<text>`, percent-decoded.
///
/// Refuses unknown fields for the same reason `PointerFact` does: a
/// parser that ignores what it does not understand cannot tell a typo
/// from an attack.
pub fn parse(body: &str) -> Result<Self, String> {
let mut text = None;
for pair in body.split('&') {
match pair.split_once('=') {
Some(("note", v)) => text = Some(percent_decode(v)),
_ => return Err(format!("unrecognised field in note: {pair:?}")),
}
}
match text {
// An empty note is refused rather than stored: a blank line in
// the trial log is noise in the register (CB-WP-0027 T03).
Some(t) if !t.trim().is_empty() => Ok(Self {
text: t.trim().to_string(),
}),
_ => Err("an empty note is not recorded".to_string()),
}
}
}
/// `application/x-www-form-urlencoded` decoding, enough for one field.
///
/// Hand-rolled because this crate has **no third-party dependencies at
/// all** and ADR-0007 §D3 requires an argument before it acquires one —
/// which a form decoder does not merit.
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
match u8::from_str_radix(hex, 16) {
Ok(b) => {
out.push(b);
i += 3;
}
// A malformed escape is kept literally rather than
// dropped: losing characters silently is how a note
// stops meaning what its author wrote.
Err(_) => {
out.push(bytes[i]);
i += 1;
}
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn action_id(a: Action) -> &'static str {
match a {
Action::Investigate => "action-investigate",

View file

@ -48,7 +48,7 @@ pub mod jsrun;
pub mod serve;
pub use doc::{document, text_of};
pub use input::{resolve, PointerFact};
pub use input::{resolve, Note, PointerFact};
pub use serve::{Guard, Refusal, Request};
#[cfg(test)]
@ -599,6 +599,7 @@ mod gamelog {
Some(PlayerId(0)),
false,
log,
&[],
)
}
@ -706,6 +707,191 @@ mod piles {
}
}
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
#[cfg(test)]
mod notes {
use cb_kernel::PlayerId;
use crate::doc::{document_with_log, text_of};
use crate::input::{resolve, Note, PointerFact};
/// **The load-bearing control (ADR-0014 D1).** A note whose text is a
/// perfectly well-formed pointer fact must not become a move.
///
/// Structural, not vigilance: `Note::parse` returns a `Note`,
/// `resolve` takes a `PointerFact`, and nothing converts between
/// them. This asserts the behaviour anyway, because "the types don't
/// connect" is a claim about code layout until something checks it.
#[test]
fn a_note_that_looks_like_a_move_is_not_one() {
let hostile = "note=down%3Daction-solve%26up%3Dproblem-1";
let note = Note::parse(hostile).expect("it parses as a note");
assert_eq!(
note.text, "down=action-solve&up=problem-1",
"the text is stored verbatim, uninterpreted"
);
// And the command parser refuses the same body outright — the two
// channels do not overlap even at the wire level.
assert!(
PointerFact::parse(hostile).is_err(),
"the command channel accepted a note body"
);
// The reverse, so this is not vacuous: a real pointer fact IS a
// command, and is NOT a note.
assert!(PointerFact::parse("down=a&up=b").is_ok());
assert!(
Note::parse("down=a&up=b").is_err(),
"the note channel accepted a pointer fact"
);
// Nothing in the crate turns a Note into a command. `resolve`'s
// signature is the proof; this pins it against a careless change.
let legal: Vec<games_ground::GroundCommand> = vec![];
assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err());
}
/// An empty note is refused, not stored — a blank row is noise in the
/// register.
#[test]
fn an_empty_note_is_refused() {
assert!(Note::parse("note=").is_err());
assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty");
assert_eq!(
Note::parse("note=%20hello%20").expect("real text").text,
"hello",
"surrounding whitespace is trimmed"
);
}
/// Percent and `+` decoding, since the form posts urlencoded.
#[test]
fn the_players_words_survive_the_wire() {
let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses");
assert_eq!(n.text, "why is SOLVE doing nothing?");
}
/// **The first hostile input this renderer has handled.** Until now
/// `esc()` escaped suit names.
#[test]
fn a_note_containing_markup_renders_as_text() {
let hostile = "<script>alert(1)</script> & \"quoted\"";
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
&[],
&[hostile.to_string()],
);
assert!(
!html.contains("<script>alert(1)</script>"),
"a note's markup reached the document unescaped"
);
assert!(
html.contains("&lt;script&gt;"),
"the note should still be visible, escaped"
);
assert!(
text_of(&html).contains("alert(1)"),
"escaping must not eat the player's words — they still read what they wrote"
);
}
/// The comment box is always offered, and the page works without it
/// being used. A control that appears only sometimes trains a player
/// not to look for it.
#[test]
fn the_comment_box_is_always_there() {
let html = document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
&[],
&[],
);
assert!(html.contains("action=\"/note\""), "no comment box");
assert!(
html.contains("method=\"post\""),
"a plain form, so it works with the script disabled"
);
}
}
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
#[cfg(test)]
mod two_columns {
use cb_kernel::PlayerId;
use crate::doc::document_with_log;
fn page(meta: &[String]) -> String {
document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
&[],
meta,
)
}
/// The columns exist, and the game is in the left one.
#[test]
fn the_table_is_in_the_game_column_and_the_log_is_not() {
let html = page(&[]);
let game = html
.split("class=\"cb-game\"")
.nth(1)
.and_then(|s| s.split("class=\"cb-meta\"").next())
.expect("a game column followed by a meta column");
assert!(
game.contains("<h2>problems</h2>"),
"the table must be in the game column"
);
assert!(
!game.contains("<h2>log</h2>"),
"the log belongs in the meta column — it is commentary on the game, not part of it"
);
}
/// **A player who writes nothing must not be worse off.** An empty
/// meta panel renders as nothing, not as an empty heading.
#[test]
fn an_empty_meta_panel_adds_no_furniture() {
assert!(
!page(&[]).contains("this session"),
"an empty session panel drew a heading with nothing under it"
);
assert!(
page(&["2 games this session".into()]).contains("this session"),
"a non-empty panel must appear — otherwise the test above passes for a panel that never renders"
);
}
/// The single-column fallback is deliberate, not incidental. Asserted
/// on the stylesheet because there is no browser here to resize —
/// which is a weaker test than laying it out, and is said so rather
/// than dressed up.
#[test]
fn the_layout_collapses_to_one_column_on_a_narrow_viewport() {
let html = page(&[]);
assert!(
html.contains("@media (max-width:64rem)"),
"no narrow-viewport rule: the two-column layout would overlap on a laptop"
);
assert!(
html.contains("minmax(0,1fr)"),
"grid children default to min-content width; without minmax(0,…) the SVG table \
refuses to shrink and pushes the meta column off-screen"
);
}
}
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
///
/// The maintainer could follow the other players only by reading the log.

View file

@ -0,0 +1,197 @@
# ADR-0014: a second channel that cannot carry a move, and a trial log that a command can read
status: accepted
date: 2026-08-06
decided by: agent, under the standing loop authorization
tier: M (structural M — touches ADR-0007 D5's input contract and the
recorded-session format; chaos d8=7 → no override). Tier M merges survey
and decision into one document, which this is.
references: [CB-WP-0027](../workplans/CB-WP-0027-the-commentary-track.md),
[ADR-0007](ADR-0007-render-html-not-a-port.md) D5,
[ADR-0012](ADR-0012-the-design-instrument.md) D6 (notes),
[GameDesign.md](../specs/GameDesign.md) §3.1 and §5
## Context
A test player wants to say *why* they played a move, and *what confused
them*, while the position is still on screen — and have it reviewable
later.
**GameDesign §5 already specifies the artifact**: a trial is a
`--record`ed session plus a sibling observation log. What it does not
specify is how the log gets written, and as written it asks the player to
reconstruct their reasoning afterwards from memory. That is why no trial
log exists yet.
## The survey, such as it is
**The candidates are all in this repo**, so there is nothing external to
benchmark against — which is why tier M's merged form is enough.
| existing mechanism | what it does | why it is not this |
|---|---|---|
| `provisional: true` + owner + date | marks an undecided *rule* | about the dataset, not about play |
| the finding register | records findings with reproductions | needs the reproduction this is meant to supply |
| `<!-- design-register:begin -->` + a table | a machine-readable block inside a human document | **the right shape — reused below** |
| the game log | what happened | not why, and not how it felt |
| `.cbreplay` bundles | the position, replayable | the anchor, not the annotation |
**The one thing worth stealing is the register's own idiom**: a table
between HTML-comment markers inside a readable Markdown file, parsed by a
small tool. It survived contact in `FindingRegister.md`, `design.py`
already parses that shape, and it means a trial log is readable by a human
without a tool and by a tool without a parser library.
---
## D1 — a comment is not a command, and the types must make that true
**ADR-0007 D5 is scoped, not amended.**
D5 says *JavaScript may not construct commands — the page reports raw
pointer facts, and Rust decides what they mean.* That rule is about the
**command channel** and it stays exactly as it is.
The relevant fact is that `PointerFact::parse` **refuses any unrecognised
field**:
```rust
_ => return Err(format!("unrecognised field in pointer fact: {pair:?}")),
```
So a comment cannot be smuggled through the existing path even by
accident. **Rather than widening that parser, the comment gets its own
one**, and the separation is structural:
> **`POST /command` carries pointer facts. `POST /note` carries text.**
> `Note` is a distinct type with **no code path to `GroundCommand`**
> `resolve()` takes a `PointerFact` and nothing else, so a note cannot
> become a move by any route, including a future careless one.
**Control:** a test posts a note whose body is a well-formed pointer fact
(`down=action-solve&up=problem-1`) and asserts **no command results and
the game does not advance**. Without it, "the channels are separate" is a
claim about code layout rather than about behaviour.
**What would falsify D1:** if `Note` ever acquires a field the engine
reads, the separation is gone and ADR-0007 says to revisit the decision
rather than widen the control. The note type carries text, a position, and
a timestamp. Nothing else.
## D2 — comments live in the trial log, not in the scenario
**Not in `ScenarioFile`.** It is `deny_unknown_fields` and CB-WP-0026 just
demonstrated why that is right — but the reason to keep prose out is not
strictness, it is that **a scenario is a game record and must stay one.**
Scenarios are executed by `make sim`, replayed by `replay-test`, and
compared by hash. A player's opinion in that file would be data the runner
must ignore, which is how a format rots.
**In `trials/<date>-<slug>.md`**, exactly as GameDesign §5 already says,
with the recording beside it as `trials/<date>-<slug>.yaml`.
The log is a readable Markdown file with one machine-readable block:
```
<!-- trial-log:begin -->
| n | round | step | state_hash | comment |
<!-- trial-log:end -->
```
**Reusing `FindingRegister.md`'s idiom deliberately** — same marker shape,
same table form, so `design.py`'s parser is the model and a reader who has
seen one has seen both.
## D3 — the binding is the state hash, with round and step for humans
A comment records **`state_hash`** (`cb_events::state_hash_hex`), plus
`round` and `step`.
- **The hash is the binding.** It is what already makes a session
comparable to its replay (`Summary::end_state_hash`), so a comment keyed
to it names a position a reader can *reach*, not merely describe.
- **Round and step are for reading.** *"Round 3, Resolve"* orients a human
instantly; a hash does not.
**A comment whose hash no longer appears in its recording is reported as
`orphaned`, not deleted.** The position may have moved because the engine
changed, and that is worth knowing — it is the same reasoning that made
`gr-e01` a rewrite rather than a deletion (CB-WP-0021 T03), and the same
signal as a reproduction that has gone green (GameDesign §1.3).
## D4 — comments stay here; only findings travel
**The retention question, decided before any comment is written.**
These are the maintainer's own words about his own game, written in the
moment, and some will be unflattering about the design, the engine, or
both. That is the point — a commentary track that people self-censor into
is worthless.
> **Raw comments never leave clay-borg.** Nothing auto-forwards to
> `ground-game`.
>
> A comment reaches `ground-game` **only** by being promoted to a register
> finding and reported through the existing path — which requires a human
> to promote it, and requires the finding to meet GameDesign §1.
So the trial log is a private notebook, and the register is the published
surface. **The promotion step is where wording gets chosen deliberately**,
which is the right place for it: *"the DARVO sequence is infuriating"* is
useful signal and a bad way to open a message to the game's designer.
**Who decides: the maintainer, per comment, at promotion time.** Not a
rule, not a default, and not the agent.
## D5 — a comment is a note, and inherits the note tier
A promoted comment enters the register as a **note** (ADR-0012 D6): kind
by judgment, state `note`, 30-day expiry on the existing machinery.
**It is not automatically a finding**, even though it has a position.
GameDesign §1 wants a reproduction that *shows the claimed thing* — a
recording proves the position existed, not that anything is wrong with it.
*"This felt pointless"* plus a replayable state is a strong note and still
a note.
**What promotes it further** is the same as for any other note: an
artifact demonstrating the defect. The trial log makes that cheap, because
the position is already recorded and someone can go and build a scenario
from it.
## D6 — surfacing is the deliverable
`make trials` reports every comment with its position and age, across all
trial logs, and flags orphans.
**Storage without surfacing would make this the third instance of this
project's signature failure** — after the four-day unread message and the
ten uncollected rulings. The workplan says no task may be called done
while comments are write-only, and this ADR agrees: **T04 is the pass, T03
is plumbing.**
**Control:** the reporting path is exercised by `--self-test`, not only
the parser. `design-baseline.py` had a green self-test and an unexercised
reporting path, and the reporting path is where it rotted (ADR-0012 D8).
## Consequences
- `cb-render-html` gains a `Note` type and a `/note` route; `PointerFact`
is untouched.
- `trials/` is created, with `.md` + `.yaml` per trial.
- `make trials` reports them; `--self-test` covers the reporting path.
- `specs/GameDesign.md` §5 gains the log's concrete format — it specified
the artifact and not its shape.
- Comments are escaped on render (`esc`), and this is the **first
user-authored text this renderer has handled**.
## What was rejected
| rejected | why |
|---|---|
| comments inside `ScenarioFile` | a scenario is a game record; prose there is data the runner must ignore |
| widening `PointerFact` to carry text | D5's parser refusing unknown fields is a control, not an inconvenience |
| free-form prose with no machine-readable block | unreadable by a command, which is the failure mode this exists to avoid |
| auto-forwarding comments to `ground-game` | invites self-censorship, and the promotion step is where wording belongs |
| treating a positioned comment as a finding | a recording proves the position existed, not that anything is wrong |
| deleting orphaned comments | a moved position is a signal, per CB-WP-0021 T03 |

View file

@ -0,0 +1,158 @@
# CB-EV-0025 — the commentary track
CB-WP-0027 T05. Tier M (structural M — touches ADR-0007 D5's input
contract and the recorded-session format; chaos d8=7 → no override).
Declaration 10 of chaos window 2. Closed 2026-08-06.
**Delivered:** [ADR-0014](../decisions/ADR-0014-the-commentary-track.md),
a two-column page, a `/note` channel, `trials/<date>-<slug>.md`,
`tools/trials.py`, `make trials`, and `--trial` on `cb-play`.
---
## 1. The question this file cannot answer
**T05 asks whether a comment actually got written during play, by the
maintainer, unprompted.** It has not been asked yet, and **that is the
pass's real acceptance test, not any of the tests below.**
The protocol's own claim is that it must cost almost nothing or it will
not be done. Everything here is evidence that the mechanism *works*;
none of it is evidence that it *gets used*. I exercised it myself, which
proves the plumbing and proves nothing about the ergonomics — the person
who wrote the feature is the worst possible witness to whether it is
worth using.
**Stated plainly rather than answered optimistically**, because
CB-WP-0022 built a register whose first run found ten answers nobody had
collected: this project's failures are about whether things get *read*,
not whether they get *built*.
## 2. What ADR-0007 D5 turned out to be
The workplan expected D5 — *JavaScript may not construct commands* — to
be the hard part. It was the easy part, for a reason worth recording:
```rust
_ => return Err(format!("unrecognised field in pointer fact: {pair:?}")),
```
**`PointerFact::parse` refuses any unrecognised field**, so a comment
could not reach the command path even by accident. A parser that ignored
what it did not understand would have made this a real decision; one that
refuses made it a non-event.
**That is a control paying off two years of maintenance early**, and it is
the clearest instance in this project of strictness at a boundary being
cheaper than vigilance across one. D5 is **scoped, not amended**: the
command channel is untouched, and the note channel is a second one that
cannot become the first because `resolve` takes a `PointerFact` and
nothing converts a `Note` into one.
**The test asserts it anyway**, with a note whose text is a well-formed
pointer fact — because "the types don't connect" is a claim about code
layout until something checks the behaviour, and the inverse is asserted
too so the test is not vacuous.
## 3. Running it changed the design, again
`make trials`'s first version called **any note without a recording an
orphan**. A recording is only written when the game ends, so **every note
in a live session reported as broken.**
A metric that cries wolf is one nobody reads — **which is the exact
failure this pass exists to prevent**, reproduced inside the tool built to
prevent it. Now three states: `ok`, `pending` (no recording yet), `orphan`
(the recording exists and the position is not in it). Only the third has a
target of zero.
**This is the third pass running where building or running the thing found
something the design did not.** CB-WP-0024's `role` column, CB-WP-0025's
`K=2` infeasibility, and this. The pattern is consistent enough to state:
**the ADRs in this project are good at deciding and poor at predicting**,
and the loop's value comes disproportionately from the code loop rather
than from steps 13.
## 4. A latent defect surfaced by a name collision
`make trials` did nothing. `trials` is also a *directory*, so Make saw an
up-to-date file and ran the recipe not at all.
Checking `.PHONY` found that **`design`, `difficulty` and `trials` — added
by CB-WP-0022, CB-WP-0025 and this pass — were all missing from it.** Two
of them work by luck: no file happens to share their name.
**Three passes added a target and none added it to `.PHONY`**, which is a
process observation rather than a bug: the Makefile has no control that a
new target is declared, and the failure is silent until a name collides.
Not fixed beyond declaring the three, because a gate for this would cost
more than it catches — but it is the kind of thing that is invisible until
it is not.
## 5. What the escaping test found
`esc()` has escaped suit names since CB-WP-0012. This is the first
user-authored free text the renderer has handled, and the source comment
*"cannot currently carry a `<`" is how injection bugs are written* — was
written in anticipation of exactly this.
It held. `<script>alert(1)</script>` renders escaped.
**The test asserts something the obvious version would miss:** that the
player's words are *still readable* afterwards. An escaper that dropped
the text would pass "no script tag in the output" while destroying the
note. Escaping that eats what someone wrote is its own defect, and only
the positive half of the assertion catches it.
## 6. Coverage cost of the reflow
**None.** All 41 pre-existing `cb-render-html` tests passed unchanged
through a full restructure of the document into two columns, including the
coverage gate that asserts every view field appears in the *parsed*
document.
**That is a stronger result than it looks.** The gate is coupled to
tokens, not to layout — so moving every element into new containers did
not disturb it. CB-WP-0024 saw the opposite when a probe was tied to a
rendering (`17 remaining`), and the contrast is the useful part: **a probe
that names a fact survives a reflow; a probe that names a presentation
does not.**
## 7. Chaos window 2
**Declaration 10 of 12.** Structural M, d8 = 7, no override.
**Twelve declarations, no 8 rolled**, so the window closes with **zero
overrides** and its retirement condition — *retire if an override changes
nothing twice running* — is untestable. CB-EV-0024 §6 said this; it is now
final rather than projected.
**The window's own verdict should be that d8 made the mechanism
unevaluable**, and that the choice was between a rate that produces
evidence and a rate that produces rarity. Window 1 at d4 produced two
overrides and both changed the outcome; window 2 at d8 produced none.
## 8. One flaky gate, reported rather than dismissed
`make all` failed once at `env-test` and passed on re-run with zero
failures. A `cb-play` server and several `cargo` processes were running
concurrently during the failing run, which is the likely cause — but
**"likely" is doing work in that sentence and I did not chase it.**
Recorded because a gate that fails intermittently and is re-run until
green is a gate being trained not to matter, and this project has enough
instances of that class already.
## Open after this pass
- **Nobody has used it yet** (§1). Until a note is written in anger, the
ergonomic claim is unsupported.
- **Promotion is manual.** ADR-0014 D5 says a comment becomes a register
note by a human's judgement, and T04 reports comments but does not
offer a one-step promotion. That was in the workplan's controls and is
the part not built.
- **The narrow-viewport rule is asserted on the stylesheet**, not on a
layout. There is no browser in the test harness that can measure a
reflow, and saying the assertion is weaker than the claim is the honest
position.
- **The flaky `env-test`** (§8) has no explanation, only a plausible one.

View file

@ -13,13 +13,14 @@
//! result is legal.
use std::cell::RefCell;
use std::fmt::Write as _;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::rc::Rc;
use cb_game_runtime::{Project, Viewer};
use cb_kernel::PlayerId;
use cb_render_html::{resolve, Guard, PointerFact, Request};
use cb_render_html::{resolve, Guard, Note, PointerFact, Request};
use games_ground::bot::{Choice, Policy};
use games_ground::{GroundCommand, GroundState};
@ -31,9 +32,71 @@ pub struct Server {
/// The live account of the game, shared with the driver (CB-WP-0018
/// T02). Read at render time so the page shows what has happened.
journal: games_ground::bot::Journal,
/// What the meta panel shows about the session (CB-WP-0027 T02).
/// Written by the driver between games, read at render time.
meta: RefCell<Vec<String>>,
/// Where the trial log is written, if this session is a trial.
trial: Option<std::path::PathBuf>,
/// Notes so far, so the log is rewritten whole rather than appended
/// to — a partial write leaves a file that neither a human nor
/// `make trials` can read.
notes: RefCell<Vec<TrialNote>>,
}
/// One thing a player said, and where they said it (ADR-0014 D3).
#[derive(Debug, Clone)]
pub struct TrialNote {
pub n: usize,
pub round: u8,
pub step: String,
pub state_hash: String,
pub text: String,
}
/// Write the trial log: readable Markdown with one machine-readable block.
///
/// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) —
/// a table between HTML-comment markers, so a human reads the file and a
/// tool reads the block, and neither needs the other's cooperation.
pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> {
let mut s = String::new();
s.push_str("# Trial log\n\n");
s.push_str(
"What the player said while playing, bound to the position they said it at\n\
(CB-WP-0027, ADR-0014). The recording beside this file is the position;\n\
`state_hash` is what reaches it.\n\n\
**These are raw notes and they stay here.** Nothing in this file travels to\n\
`ground-game` (ADR-0014 D4) a note reaches them only by being promoted to a\n\
register finding, by a human, with the wording chosen then.\n\n",
);
s.push_str("<!-- trial-log:begin -->\n\n");
s.push_str("| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n");
for note in notes {
// Pipes and newlines would break the table, so they are replaced
// rather than escaped: the note is prose, and a reader losing a
// literal `|` matters less than a log nothing can parse.
let text = note.text.replace(['\n', '\r'], " ").replace('|', "/");
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
note.n, note.round, note.step, note.state_hash, text
);
}
s.push_str("\n<!-- trial-log:end -->\n");
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("trial dir: {e}"))?;
}
std::fs::write(path, s).map_err(|e| format!("write trial log: {e}"))
}
impl Server {
/// Where this session's trial log goes. Without one, the note
/// channel refuses rather than dropping what the player wrote.
pub fn with_trial(mut self, path: Option<std::path::PathBuf>) -> Self {
self.trial = path;
self
}
pub fn bind(port: u16) -> Result<Self, String> {
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
let port = listener
@ -45,6 +108,9 @@ impl Server {
guard: Guard::mint(port),
log: RefCell::new(Vec::new()),
journal: games_ground::bot::Journal::default(),
meta: RefCell::new(Vec::new()),
trial: None,
notes: RefCell::new(Vec::new()),
})
}
@ -54,6 +120,42 @@ impl Server {
self.guard.page_url()
}
/// Append a note to the trial log, bound to the position it was
/// written at (ADR-0014 D2/D3).
///
/// **The state hash is the binding.** Round and step orient a reader;
/// the hash is what lets one *reach* the position, because it is the
/// same value that makes a session comparable to its replay.
fn record_note(&self, state: &GroundState, note: &Note) -> Result<(), String> {
let Some(path) = self.trial.as_ref() else {
// No trial log configured: refuse rather than drop. A note
// that vanishes is worse than a note that was never offered,
// and this is the failure mode the whole pass exists to avoid.
return Err("no trial log for this session — start with --trial".into());
};
let hash = cb_events::state_hash_hex(state);
let mut log = self.notes.borrow_mut();
let n = log.len() + 1;
log.push(TrialNote {
n,
round: state.round,
step: format!("{:?}", state.step),
state_hash: hash[..12].to_string(),
text: note.text.clone(),
});
write_trial_log(path, &log)
}
/// Set what the meta panel shows about the session (CB-WP-0027 T02).
///
/// Called by the driver between games, so the running tally is visible
/// **while playing** rather than only on the ending page — which is
/// where it was, and a tally you only see once the game is over
/// informs nothing.
pub fn set_meta(&self, lines: Vec<String>) {
*self.meta.borrow_mut() = lines;
}
/// The journal the driver appends to; hand it to `play_journaled`.
pub fn journal(&self) -> games_ground::bot::Journal {
self.journal.clone()
@ -145,6 +247,10 @@ impl Server {
Some(seat),
may_pass,
&self.log_lines(),
// CB-WP-0027 T02: the meta panel's own content.
// The running tally lives with the driver, so the
// server is handed the lines rather than the state.
&self.meta.borrow(),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
@ -170,6 +276,23 @@ impl Server {
Err(why) => respond(&mut stream, 200, "text/plain", &why),
}
}
// CB-WP-0027 T03 / ADR-0014 D1. A SECOND CHANNEL, and it
// cannot carry a move: `Note::parse` returns a `Note`,
// `resolve` takes a `PointerFact`, and there is no
// conversion between them. The game does not advance here
// and the loop keeps waiting for the seat's actual move.
("POST", "/note") => match Note::parse(&req.body) {
Ok(note) => match self.record_note(state, &note) {
Ok(()) => {
// 303 so the browser re-GETs the table rather
// than leaving a form POST in history — a
// reload would otherwise re-submit the note.
respond_seeother(&mut stream, "/");
}
Err(e) => respond(&mut stream, 500, "text/plain", &e),
},
Err(why) => respond(&mut stream, 400, "text/plain", &why),
},
_ => respond(&mut stream, 404, "text/plain", "no such thing here"),
}
}
@ -554,6 +677,15 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
/// 303 See Other, so the browser re-GETs the table after a form POST
/// rather than leaving the POST in history — a reload would otherwise
/// re-submit the note and duplicate it.
fn respond_seeother(stream: &mut TcpStream, to: &str) {
let head = format!("HTTP/1.1 303 See Other\r\nLocation: {to}\r\nContent-Length: 0\r\n\r\n");
let _ = stream.write_all(head.as_bytes());
let _ = stream.flush();
}
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
let reason = match status {
200 => "OK",
@ -658,6 +790,7 @@ mod tests {
replay_dir: None,
record: None,
serve: Some(0),
trial: None,
},
std::io::Cursor::new(Vec::new()),
out,
@ -825,6 +958,7 @@ mod tests {
replay_dir: None,
record: None,
serve: Some(0),
trial: None,
},
std::io::Cursor::new(Vec::new()),
out,

View file

@ -762,6 +762,7 @@ mod tests {
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
serve: None,
trial: None,
};
let mut sink: Vec<u8> = Vec::new();
let summary =

View file

@ -28,6 +28,7 @@ play:
--bot KIND policy for every other seat: greedy (default) or random
--replay DIR write a .cbreplay bundle of the finished game to DIR
--record FILE write the finished game as a scenario YAML
--trial FILE write a trial log: what the player said, bound to where
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
of on the terminal; 0 lets the OS pick. Prints a URL
carrying a per-process token without it the page is
@ -104,6 +105,13 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
config.record = Some(value(i, argv, flag)?.into());
i += 2;
}
// CB-WP-0027: a trial is a recorded session PLUS what the
// player said while playing (GameDesign §5, ADR-0014).
"--trial" => {
play_flags.push(flag.into());
config.trial = Some(value(i, argv, flag)?.into());
i += 2;
}
"--replay" => {
play_flags.push(flag.into());
config.replay_dir = Some(value(i, argv, flag)?.into());
@ -260,6 +268,7 @@ mod tests {
replay_dir: None,
record: None,
serve: None,
trial: None,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
@ -299,6 +308,7 @@ mod tests {
replay_dir: None,
record: None,
serve: None,
trial: None,
};
let mut out: Vec<u8> = Vec::new();
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
@ -361,6 +371,7 @@ mod tests {
replay_dir: None,
record: None,
serve: None,
trial: None,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
@ -421,6 +432,7 @@ mod tests {
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
serve: None,
trial: None,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");

View file

@ -36,6 +36,9 @@ pub struct Config {
/// Serve human seats in a browser instead of on the terminal
/// (ADR-0007). `Some(0)` lets the OS pick the port.
pub serve: Option<u16>,
/// Where the trial log goes (CB-WP-0027). Without it the note channel
/// refuses rather than dropping what the player wrote.
pub trial: Option<std::path::PathBuf>,
}
impl Default for Config {
@ -48,6 +51,7 @@ impl Default for Config {
replay_dir: None,
record: None,
serve: None,
trial: None,
}
}
}
@ -287,7 +291,9 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
// pointing at a dead port. Caught by `play_again_deals_a_second_game`.
let server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
let s = std::rc::Rc::new(
crate::hotseat::Server::bind(port)?.with_trial(config.trial.clone()),
);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
@ -301,6 +307,11 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
// because those are the other two things that survive `play again`.
let mut tally = MatchTally::default();
loop {
// CB-WP-0027 T02: the panel shows the series *during* the next
// game, not only after it.
if let Some(s) = &server {
s.set_meta(crate::hotseat::series_lines(&tally));
}
let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?;
if choice != crate::hotseat::EndChoice::Again {
return Ok(summary);

201
tools/trials.py Normal file
View file

@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""trials — what the players said, and where (CB-WP-0027 T04).
**Surfacing is the deliverable, not storage.** ADR-0014 D6: a commentary
feature that stores comments and shows them nowhere would be this
project's signature failure in a new medium — after the message that sat
unread for four days and the ten rulings that arrived and were never
collected.
So this reports every note from every trial log, with its position and its
age, and flags the ones whose position can no longer be reached.
Reuses `design.py`'s parsing shape because the trial log deliberately
reuses `FindingRegister.md`'s idiom: a table between HTML-comment markers
inside a readable Markdown file.
"""
import os, re, sys, glob, datetime
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRIALS = os.path.join(ROOT, "trials")
BEGIN = "<!-- trial-log:begin -->"
END = "<!-- trial-log:end -->"
# GameDesign §3.1's figure, shared rather than reinvented: a note that
# cannot expire ages into an apparent finding.
NOTE_EXPIRY_DAYS = 30
def parse(text):
"""Rows between the markers. A log without the block is an error, not
an empty log silently reporting zero notes for a file full of them
is precisely the failure this tool exists to prevent."""
if BEGIN not in text or END not in text:
raise ValueError("no trial-log block")
block = text.split(BEGIN)[1].split(END)[0]
rows = []
for line in block.splitlines():
line = line.strip()
if not line.startswith("|") or line.startswith("|---"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) != 5 or cells[0] == "n":
continue
rows.append(dict(zip(("n", "round", "step", "state_hash", "comment"), cells)))
return rows
def logs(root=TRIALS):
"""Every trial log, with its recording beside it if there is one."""
out = []
for path in sorted(glob.glob(os.path.join(root, "*.md"))):
try:
rows = parse(open(path).read())
except ValueError:
print(f" WARN {os.path.basename(path)}: no trial-log block — not a trial log?")
continue
recording = os.path.splitext(path)[0] + ".yaml"
out.append((path, rows, recording if os.path.exists(recording) else None))
return out
def reachability(row, recording):
"""Can this note's position be reached? One of three answers.
**"No recording" and "the hash is not in the recording" are different
states and must not be one number.** A note written mid-game is
pending the recording is only written when the game ends while a
note whose hash is absent from a recording that *exists* means the
position moved. Collapsing them makes every live session report
orphans, and a metric that cries wolf is one nobody reads, which is
the failure this whole pass is about.
Orphans are reported, never deleted (ADR-0014 D3): a moved position is
worth knowing, by the same reasoning that rewrote `gr-e01` rather than
retiring it.
"""
if not recording:
return "pending"
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
def age_days(path, today):
"""Dated from the log's filename (`YYYY-MM-DD-slug.md`), falling back
to mtime the name is the intent, the mtime is what happened."""
m = re.match(r"(\d{4}-\d{2}-\d{2})", os.path.basename(path))
if m:
try:
return (today - datetime.date.fromisoformat(m.group(1))).days
except ValueError:
pass
return (today - datetime.date.fromtimestamp(os.path.getmtime(path))).days
def report(root=TRIALS, today=None):
today = today or datetime.date.today()
if not os.path.isdir(root):
print("trials — no trials/ directory yet\n")
print(" Play one: cb-play --serve 0 --record trials/<date>-<slug>.yaml \\")
print(" --trial trials/<date>-<slug>.md")
return 0
found = logs(root)
print("trials — what the players said, and where\n")
total, orphans, pending, expired = 0, 0, 0, 0
for path, rows, recording in found:
name = os.path.basename(path)
age = age_days(path, today)
print(f" {name} ({len(rows)} note(s), {age}d"
f"{', recording not written yet' if not recording else ''})")
for r in rows:
total += 1
state = reachability(r, recording)
orphans += state == "orphan"
pending += state == "pending"
expired += age > NOTE_EXPIRY_DAYS
mark = {"orphan": "orphan", "pending": " .. ", "ok": " "}[state]
print(f" {mark} r{r['round']:<2} {r['step']:<8} {r['state_hash']:<12} "
f"{r['comment'][:70]}")
print()
print(f" trial logs {len(found)}")
print(f" notes {total}")
print(f" positions unreachable {orphans} target 0"
+ (" <-- the recording exists but the position moved" if orphans else ""))
if pending:
print(f" awaiting a recording {pending}"
" (normal during a live session; the recording lands at game end)")
print(f" notes past {NOTE_EXPIRY_DAYS} days {expired} target 0")
if total and not orphans:
print("\n Every note points at a position a reader can reach.")
print("\n Raw notes stay in this repo (ADR-0014 D4). A note reaches"
"\n ground-game only by being promoted to a register finding, by a"
"\n human, with the wording chosen then.")
return 0
def self_test():
"""Positive controls, including for the REPORTING path.
`design-baseline.py` had a green self-test and an unexercised
reporting path, and the reporting path is where it rotted (ADR-0012
D8). So this runs `report` against a fixture and checks what it says.
"""
import tempfile, io, contextlib
ok = True
def check(name, cond, detail=""):
nonlocal ok
ok = ok and bool(cond)
print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f"{detail}" if detail else ""))
good = (f"# Trial log\n\n{BEGIN}\n\n"
"| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"
"| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n"
f"\n{END}\n")
check("a trial log parses", len(parse(good)) == 1)
check("the header row is not a note", all(r["n"] != "n" for r in parse(good)))
try:
parse("# Trial log\n\nno block here\n")
check("a file with no block is an error, not an empty log", False)
except ValueError:
check("a file with no block is an error, not an empty log", True,
"silently reporting zero is the failure this tool prevents")
with tempfile.TemporaryDirectory() as d:
open(os.path.join(d, "2026-08-06-x.md"), "w").write(good)
# No .yaml beside it → the position cannot be reached.
rows = parse(good)
check("a note with no recording is PENDING, not orphaned",
reachability(rows[0], None) == "pending",
"a live session must not report orphans")
rec = os.path.join(d, "2026-08-06-x.yaml")
open(rec, "w").write("state_hash: abc123def456\n")
check("a note whose hash is in the recording is reachable",
reachability(rows[0], rec) == "ok",
"without this the check could always say orphan")
open(rec, "w").write("state_hash: something-else\n")
check("a note whose hash is absent from a REAL recording is an orphan",
reachability(rows[0], rec) == "orphan",
"the position moved \u2014 the case the flag exists for")
# THE control design-baseline.py lacked: exercise the reporting
# path and assert on what it printed.
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
report(d, datetime.date(2026, 8, 6))
out = buf.getvalue()
check("the reporting path runs and names the note",
"why is SOLVE doing nothing" in out, "not just the parser")
check("the reporting path counts", "notes 1" in out)
print("trials self-test (positive control)")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(self_test() if "--self-test" in sys.argv else report())

View file

@ -2,7 +2,7 @@
id: CB-WP-0027
kind: product
title: "The commentary track: the meta view beside the table, and what the player says while playing"
status: ready
status: done
state_hub_workstream_id: "e011da8d-de9e-48bf-a3f2-a9e715ef222f"
---
@ -80,7 +80,7 @@ write-only.
```task
id: CB-WP-0027-T01
status: todo
status: done
priority: high
state_hub_task_id: "3586f3af-ad45-4bc6-bea2-b6eaedf05fbf"
```
@ -117,11 +117,40 @@ about his own game**, and one of them will eventually be unflattering
about the design or about the engine. State whether they are private to
the repo, whether they travel to `ground-game`, and who decides.
**Done 2026-08-06.**
[ADR-0014](../decisions/ADR-0014-the-commentary-track.md), six decisions.
**(b) turned out to be the easy one, because the control already exists.**
`PointerFact::parse` **refuses any unrecognised field**, so a comment
cannot reach the command path even by accident. **D5 is scoped, not
amended**: `/command` carries pointer facts, `/note` carries text, and
`Note` has **no code path to `GroundCommand`**`resolve()` takes a
`PointerFact` and nothing else. The control is a test that posts a note
whose body is a well-formed pointer fact and asserts the game does not
advance; without it, "separate channels" is a claim about code layout.
**(a) the trial log, not the scenario** — and the reason is not
strictness. A scenario is executed, replayed and hashed; prose in it is
data the runner must ignore, which is how a format rots. The log reuses
`FindingRegister.md`'s idiom: a table between HTML-comment markers, so
`design.py`'s parser is the model.
**(c) the state hash binds; round and step are for reading.** An orphaned
comment — hash no longer in its recording — is **reported, not deleted**,
same reasoning as `gr-e01`'s rewrite and a green reproduction's alarm.
**Retention: raw comments never leave clay-borg.** A comment reaches
`ground-game` only by being promoted to a register finding, by a human, at
which point the wording is chosen deliberately. *"The DARVO sequence is
infuriating"* is useful signal and a bad way to open a message to the
game's designer. **The maintainer decides per comment** — not a rule, not
a default, not the agent.
## Task: the table on the left, the meta on the right
```task
id: CB-WP-0027-T02
status: todo
status: done
priority: high
state_hub_task_id: "0971185c-b72c-43ec-a356-c221ef6d8165"
```
@ -150,11 +179,29 @@ not part of the table.
- the game column must be usable with the meta column collapsed. A player
who does not want to write anything must not be worse off.
**Done 2026-08-06.** CSS grid, `minmax(0,1fr)` on both tracks and a
single-column fallback under 64rem.
**`minmax(0, …)` is the load-bearing part.** A grid child defaults to
min-content width, so without it the SVG table refuses to shrink and
pushes the meta column off-screen instead of narrowing — the layout would
look correct on the developer's monitor and be broken everywhere else.
**The running tally moved into the panel**, so it is visible *while
playing*. It only appeared on the ending page before, and a session score
you see once the game is over informs nothing.
Three tests: the log is in the meta column and the table is not; an empty
panel draws **no furniture** (with the inverse, so it does not pass for a
panel that never renders); and the narrow-viewport rule exists — asserted
on the stylesheet, which is **weaker than laying it out**, and said so
rather than dressed up.
## Task: capture what the player says, bound to where they said it
```task
id: CB-WP-0027-T03
status: todo
status: done
priority: high
state_hub_task_id: "4a23ba0d-9b23-4381-b6b0-2b8959106270"
```
@ -178,11 +225,33 @@ first time that comment is load-bearing rather than precautionary.
- **the game is playable with comments disabled**, and a test says so. The
commentary track must not become a dependency of playing.
**Done 2026-08-06.** `Note` in `input.rs`, a `/note` route, `--trial`, and
`trials/<date>-<slug>.md` written whole on every note.
**The separation is structural, and tested anyway.** A note whose text is
a well-formed pointer fact (`down=action-solve&up=problem-1`) parses as a
note, is stored verbatim, and **the command channel refuses the same body
outright** — with the inverse asserted too, so the test is not vacuous.
**A plain `<form method="post">`**, so the comment box works with the
script disabled. The command channel needs JavaScript because a drag is
not a form submission; a comment is one, and making it depend on the
script would add a failure mode for nothing. The reply is **303 See
Other**, so a reload does not re-post the note.
**Verified over real HTTP**, not only in tests: two columns served, a note
posted (303), a hostile note stored as text (303), an empty note refused
(400), and the game did not advance.
**`esc()`'s first hostile input.** `<script>alert(1)</script>` renders
escaped **and still readable** — the test asserts the player's words
survive, because escaping that eats the text is its own defect.
## Task: surface them, or this pass has failed
```task
id: CB-WP-0027-T04
status: todo
status: done
priority: high
state_hub_task_id: "df8afbef-6ff7-4aa2-9ff7-b3fa05f676cc"
```
@ -209,11 +278,33 @@ session that produced them, by a command, without opening a file by hand.
`tools/design.py` accepts — if the register cannot express it, the shape
is wrong and that is a finding about ADR-0012, not a bug.
**Done 2026-08-06.** `tools/trials.py`, `make trials`, wired into
`make self-tests`.
**The report distinguishes three states, and that distinction came from
running it.** The first version called any note without a recording an
**orphan** — so a live session reported every note as broken, because the
recording is only written at game end. A metric that cries wolf is one
nobody reads, **which is the exact failure this pass exists to prevent**.
Now: `ok`, `pending` (no recording yet), `orphan` (the recording exists
and the position is not in it). Only the third is a target-0 number.
**The self-test exercises the reporting path**, not just the parser — it
runs `report` against a fixture and asserts on what it printed.
`design-baseline.py` had a green self-test and an unexercised reporting
path, and that is where it rotted (ADR-0012 D8).
**A latent Makefile defect surfaced.** `make trials` did nothing: `trials`
is also a *directory*, so Make saw an up-to-date file. `design`,
`difficulty` and `trials` — added by CB-WP-0022, CB-WP-0025 and this pass
— were **all** missing from `.PHONY`; only the one that collided revealed
it. All three are declared now.
## Task: evidence
```task
id: CB-WP-0027-T05
status: todo
status: done
priority: medium
state_hub_task_id: "c0beb2d6-f998-4c36-aafa-c9ebd10c5086"
```
@ -233,3 +324,31 @@ state_hub_task_id: "c0beb2d6-f998-4c36-aafa-c9ebd10c5086"
says about how tightly the gate is coupled to the document's shape.
- **Quote CB-WP-0025's cost by re-running the instrument** — and note that
CB-EV-0019 §4's unbounded chain is still unbounded.
**Done 2026-08-06.**
[CB-EV-0025](../evidence/CB-EV-0025-the-commentary-track.md).
**The first bullet is unanswered, and that is the honest result.** Nobody
has written a note in anger. I exercised the mechanism myself, which
proves the plumbing and proves nothing about the ergonomics — **the person
who wrote the feature is the worst possible witness to whether it is worth
using.** The protocol's claim is that it must cost almost nothing or it
will not be done, and that remains untested.
- **ADR-0007 D5 was the easy part**, because `PointerFact::parse` already
refuses unrecognised fields — strictness at a boundary paying off years
early, and the clearest instance of it in this project.
- **`esc()` held**, and the test asserts the harder half: the player's
words are still *readable* after escaping. An escaper that dropped the
text would pass "no script tag" while destroying the note.
- **The reflow cost no coverage probe** — all 41 tests passed through a
full restructure. CB-WP-0024 saw the opposite when a probe named a
*rendering* rather than a *fact*, and the contrast is the useful part.
- **Running it changed the design for the third pass running** (after
CB-WP-0024's `role` column and CB-WP-0025's `K=2`). The ADRs here are
good at deciding and poor at predicting.
- **Chaos window 2 closes with zero overrides**, so its retirement
condition is untestable — now final rather than projected.
**Not built, and named:** promotion of a comment to a register note is
still manual. `make trials` reports; it does not promote.