diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md
index 7bd4a2b..c672249 100644
--- a/WORK-RECORDS.md
+++ b/WORK-RECORDS.md
@@ -37,7 +37,7 @@
| workplan | CB-WP-0027 | done | — | workplans/CB-WP-0027-the-commentary-track.md |
| workplan | CB-WP-0028 | done | — | workplans/CB-WP-0028-the-table-you-sit-at.md |
| workplan | CB-WP-0029 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
-| workplan | CB-WP-0030 | ready | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
+| workplan | CB-WP-0030 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
| task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md |
| task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
| task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md |
@@ -195,7 +195,7 @@
| task | CB-WP-0029-T02 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
| task | CB-WP-0029-T03 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
| task | CB-WP-0029-T04 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md |
-| task | CB-WP-0030-T01 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
-| task | CB-WP-0030-T02 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
-| task | CB-WP-0030-T03 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
-| task | CB-WP-0030-T04 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
+| task | CB-WP-0030-T01 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
+| task | CB-WP-0030-T02 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
+| task | CB-WP-0030-T03 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
+| task | CB-WP-0030-T04 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md |
diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index 6cc199f..993e735 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -1004,6 +1004,33 @@ pub fn document_with_log(
s
}
+/// The comment box, shared by the playing page and the ending page.
+///
+/// **Extracted because the ending page needs the same box** (CB-WP-0031).
+/// A second copy would be a second thing to forget: the playing page's
+/// form already had the token-carrying action and the works-without-script
+/// property, and a hand-written twin on the ending page is how one of them
+/// quietly stops posting anywhere useful.
+///
+/// The `placeholder` differs, and only that — what a player has to say
+/// mid-turn and what they have to say having seen the result are not the
+/// same prompt.
+fn note_form(s: &mut String, note_to: &str, placeholder: &str) {
+ // 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.
+ let _ = write!(
+ s,
+ "
what are you thinking?
\
+ ",
+ note_to = esc(note_to),
+ placeholder = esc(placeholder),
+ );
+}
+
/// The meta panel's own content: whatever the caller wants a player to see
/// *about* the session rather than about the position.
///
@@ -1018,14 +1045,10 @@ fn meta_section(s: &mut String, meta: &[String], note_to: &str) {
// 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.
- let _ = write!(
+ note_form(
s,
- "
what are you thinking?
\
- ",
- note_to = esc(note_to),
+ note_to,
+ "why this move, what is unclear, what is annoying \u{2014} bound to this position",
);
if meta.is_empty() {
return;
@@ -1423,10 +1446,19 @@ fn rankings(s: &mut String, view: &GroundView) {
/// **This page does not auto-reload.** The old one reloaded on `ok` and
/// the reload was refused, which is how a completed game became a blank
/// tab.
+///
+/// **`note_to` is `Some` when a note can still be anchored** (CB-WP-0031).
+/// The comment box used to stop existing the moment the game ended, which
+/// removed the channel exactly when a player has most to say — they have
+/// just seen the outcome, and *"so that's why that failed"* is the reading
+/// the whole trial protocol wants. It is `None` only when there is no
+/// final position to bind a note to, and then the page says so rather than
+/// showing a box that would refuse.
pub fn ending(
view: Option<&GroundView>,
message: &str,
endpoint: &str,
+ note_to: Option<&str>,
log: &[LogLine],
series: &[String],
) -> String {
@@ -1478,6 +1510,21 @@ pub fn ending(
}
s.push_str("");
}
+ // CB-WP-0031: above the log and below the result, because that is the
+ // order the thought arrives in — you see how it ended, you say what
+ // you make of it, and the account is there to check yourself against.
+ match note_to {
+ Some(to) => note_form(
+ &mut s,
+ to,
+ "how did that go \u{2014} what surprised you, what was unclear, \
+ what would you do differently",
+ ),
+ None => s.push_str(
+ "
No comment box: the game stopped without a \
+ final position, so there is nothing to bind a note to.
",
+ ),
+ }
// Observations 6 and 7: the controls and the full log belong beside
// the result, not under it. And the controls go BELOW the log — you
// read what happened, then decide what to do next.
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 7663d4e..e613a3b 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -1360,7 +1360,7 @@ mod ending_page {
use crate::{doc, jsrun};
fn page() -> String {
- doc::ending(None, "the game ended", "/command?t=x", &[], &[])
+ doc::ending(None, "the game ended", "/command?t=x", None, &[], &[])
}
/// The label must say what the control DOES. Asserted on the rendered
@@ -1439,7 +1439,7 @@ mod ending_page {
#[test]
fn click_targets_do_not_wear_the_drag_affordance() {
let pages = [
- doc::ending(None, "m", "/command?t=x", &[], &[]),
+ doc::ending(None, "m", "/command?t=x", None, &[], &[]),
doc::document(
&crate::testfix::view(Some(PlayerId(0))),
&[games_ground::GroundCommand::SelectAction {
@@ -1490,7 +1490,7 @@ mod ending_page {
o.group_success = false;
}
let head = |v: &games_ground::view::GroundView| {
- crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[]))
+ crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", None, &[], &[]))
};
assert!(
head(&won).contains("game solved"),
@@ -1511,6 +1511,7 @@ mod ending_page {
None,
"P1 ran out of input",
"/command?t=x",
+ None,
&[],
&[],
));
@@ -1525,7 +1526,7 @@ mod ending_page {
fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() {
let mut v = crate::testfix::view(None);
v.mode = games_ground::ScoringMode::SharedGround;
- let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
+ let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", None, &[], &[]));
assert!(
text.contains("problems solved"),
@@ -1548,7 +1549,7 @@ mod ending_page {
fn a_ranked_mode_cites_the_games_own_tiebreak() {
let mut v = crate::testfix::view(None);
v.mode = games_ground::ScoringMode::BondedCoalitions;
- let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
+ let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", None, &[], &[]));
assert!(
text.contains("Lower combined Stress"),
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index ae39180..c27f50d 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -126,7 +126,19 @@ impl Server {
/// **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> {
+ /// `step` is passed in rather than read off the state (CB-WP-0031),
+ /// because a note written after the game has ended is not a note taken
+ /// at the final decision point and must not be logged as one. The
+ /// position it binds to is genuinely the final state — `round` and
+ /// `state_hash` are that state's — but *when the player said it* is a
+ /// different fact, and conflating them would file an after-the-fact
+ /// reading as an in-play observation.
+ ///
+ /// `"after the end"` cannot collide with a real step: `RoundStep`'s
+ /// `Debug` values are bare identifiers (`Select`, `Reveal`, `Resolve`,
+ /// `End`), and **`End` is the last step of a round, not the end of the
+ /// game** — which is exactly the confusion this label avoids.
+ fn record_note(&self, state: &GroundState, step: &str, 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,
@@ -139,7 +151,7 @@ impl Server {
log.push(TrialNote {
n,
round: state.round,
- step: format!("{:?}", state.step),
+ step: step.to_string(),
state_hash: hash[..12].to_string(),
text: note.text.clone(),
});
@@ -285,21 +297,23 @@ impl Server {
// 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, ¬e) {
- 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.
- //
- // WITH THE TOKEN. Redirecting to bare `/` sent
- // the browser to a request control 1 refuses,
- // so the note was saved and the player was
- // shown "no session token" — which reads as
- // the note having failed.
- respond_seeother(&mut stream, &self.guard.page_path());
+ Ok(note) => {
+ match self.record_note(state, &format!("{:?}", state.step), ¬e) {
+ 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.
+ //
+ // WITH THE TOKEN. Redirecting to bare `/` sent
+ // the browser to a request control 1 refuses,
+ // so the note was saved and the player was
+ // shown "no session token" — which reads as
+ // the note having failed.
+ respond_seeother(&mut stream, &self.guard.page_path());
+ }
+ Err(e) => respond(&mut stream, 500, "text/plain", &e),
}
- 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"),
@@ -327,9 +341,18 @@ impl Server {
/// seen — the terminal page carries a `done` control and posts it —
/// with `linger` as a bound so an abandoned tab cannot hold the
/// process open forever.
+ ///
+ /// **`final_state` is what makes the comment box work here**
+ /// (CB-WP-0031). This loop had no `/note` arm at all, so a note posted
+ /// from the ending page fell through to `404 the game is over` — and
+ /// the ending page did not render the box in the first place, so the
+ /// channel closed at the moment a player has just learned how it went.
+ /// `None` means the game stopped without a position, and then the box
+ /// is not offered rather than offered and refused.
pub fn serve_end(
&self,
final_view: Option<&games_ground::view::GroundView>,
+ final_state: Option<&GroundState>,
message: &str,
linger: std::time::Duration,
tally: &crate::table::MatchTally,
@@ -365,15 +388,44 @@ impl Server {
}
match (req.method.as_str(), req.path.as_str()) {
("GET", "/") => {
+ let note_to = final_state.map(|_| self.guard.note_endpoint());
let page = cb_render_html::doc::ending(
final_view,
message,
&self.guard.endpoint(),
+ note_to.as_deref(),
&self.log_lines(),
&series_lines(tally),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
+ // CB-WP-0031. The same second channel as in `next_choice`,
+ // and it still cannot carry a move — here it could not
+ // anyway, because the game is finished.
+ //
+ // **It does not `break`.** Every other POST here ends the
+ // loop; a note must leave the session exactly as it found
+ // it, so the player can write a second one, or read the
+ // log again, or then press `play again`.
+ ("POST", "/note") => {
+ let Some(state) = final_state else {
+ respond(
+ &mut stream,
+ 409,
+ "text/plain",
+ "the game stopped without a final position, so a note \
+ has nothing to bind to",
+ );
+ continue;
+ };
+ match Note::parse(&req.body) {
+ Ok(note) => match self.record_note(state, "after the end", ¬e) {
+ Ok(()) => respond_seeother(&mut stream, &self.guard.page_path()),
+ Err(e) => respond(&mut stream, 500, "text/plain", &e),
+ },
+ Err(why) => respond(&mut stream, 400, "text/plain", &why),
+ }
+ }
("POST", "/command") => {
// CB-WP-0020 T05: the browser could not start a second
// game without going back to a terminal, which made it
@@ -750,6 +802,7 @@ mod tests {
server
.serve_end(
Some(&view),
+ Some(&state()),
"30 commands, hash f6c890a65271",
std::time::Duration::from_secs(20),
&crate::table::MatchTally::default(),
@@ -793,6 +846,104 @@ mod tests {
assert!(replies[1].contains("closed"), "{}", replies[1]);
}
+ /// CB-WP-0031. The comment box must survive the end of the game.
+ ///
+ /// **Two things go wrong independently and this asserts both.** The
+ /// ending page did not render the form, and `serve_end` had no
+ /// `/note` arm — so even a hand-built POST fell through to
+ /// `404 the game is over`. Fixing either alone leaves the channel
+ /// shut, so a test that only checked the markup would have passed
+ /// against a server that still refused.
+ ///
+ /// **And the note must not end the session.** Every other POST in
+ /// this loop breaks it; a player writing what they thought must be
+ /// able to write a second one and then still press `play again`,
+ /// which is what the trailing command here proves.
+ #[test]
+ fn a_note_can_be_written_after_the_game_has_ended() {
+ let dir = std::env::temp_dir().join(format!("cb-note-end-{}", std::process::id()));
+ let log = dir.join("trial.md");
+ let server = Server::bind(0).expect("bind").with_trial(Some(log.clone()));
+ let port = server.listener.local_addr().unwrap().port();
+ let token = server.guard.token().to_string();
+ let client = converse(
+ port,
+ vec![
+ format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
+ // The body is its own binding and the length is computed
+ // from it. Writing `Content-Length: 21` by hand beside a
+ // 22-byte body is a test that fails for a reason having
+ // nothing to do with the feature.
+ post(&token, "/note", "note=so+that+is+why+it"),
+ post(&token, "/command", "down=done&up=done"),
+ ],
+ );
+ let view = state().project(Viewer::Spectator);
+ server
+ .serve_end(
+ Some(&view),
+ Some(&state()),
+ "30 commands",
+ std::time::Duration::from_secs(20),
+ &crate::table::MatchTally::default(),
+ )
+ .expect("serve_end");
+ let replies = client.join().expect("client");
+
+ assert!(
+ replies[0].contains("id=\"cb-note\""),
+ "the ending page offered no comment box"
+ );
+ // 303 back to the TOKEN-CARRYING path. Redirecting to bare `/`
+ // is the defect that made a SAVED note read as a failed one.
+ assert!(
+ replies[1].contains("303"),
+ "the note was refused: {}",
+ replies[1]
+ );
+ assert!(
+ replies[1].contains(&format!("t={token}")),
+ "the redirect dropped the token, which reads as a refusal: {}",
+ replies[1]
+ );
+ // The session survived the note.
+ assert!(
+ replies[2].contains("closed"),
+ "the note ended the session, so nothing could follow it: {}",
+ replies[2]
+ );
+
+ let written = std::fs::read_to_string(&log).expect("trial log");
+ assert!(written.contains("so that is why it"), "{written}");
+ // The WHEN is not the final step. `RoundStep::End` is the last
+ // step of a round; filing an after-the-fact reading under it
+ // would claim the player said this at a decision point.
+ assert!(
+ written.contains("after the end"),
+ "a post-game note was logged as an in-play one: {written}"
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The other half of the same decision: no position, no box.
+ ///
+ /// A crashed game has nothing to bind a note to, and offering a box
+ /// that would answer 409 is worse than not offering one.
+ #[test]
+ fn a_game_that_stopped_offers_no_comment_box() {
+ let mut s = String::new();
+ let page = cb_render_html::doc::ending(None, "it broke", "/c", None, &[], &[]);
+ s.push_str(&page);
+ assert!(
+ !s.contains("id=\"cb-note\""),
+ "offered a box with nothing to bind to"
+ );
+ assert!(
+ s.contains("nothing to bind a note to"),
+ "did not say why: {s}"
+ );
+ }
+
/// **CB-WP-0020 T05.** "play again" must deal a second game, not just
/// answer politely and exit — the browser could not start a second
/// game without going back to a terminal, which made it a strictly
@@ -857,8 +1008,14 @@ mod tests {
/// game that never happened.
#[test]
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
- let page =
- cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[], &[]);
+ let page = cb_render_html::doc::ending(
+ None,
+ "P1 ran out of input",
+ "/command?t=x",
+ None,
+ &[],
+ &[],
+ );
assert!(
page.contains("P1 ran out of input"),
"the reason is missing"
@@ -1105,9 +1262,17 @@ mod tests {
)
}
- fn post(token: &str, body: &str) -> String {
+ /// One form POST. **`Content-Length` is derived from the body**, which
+ /// is the whole reason to have this rather than hand-written request
+ /// literals: the first draft of the post-game note test wrote
+ /// `Content-Length: 21` beside a 22-byte body, the server read 21 of
+ /// them, and the note came back `400 unrecognised field` — a failure
+ /// that looked exactly like the feature being broken.
+ ///
+ /// `path` because the note channel is a second endpoint (CB-WP-0031).
+ fn post(token: &str, path: &str, body: &str) -> String {
format!(
- "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
+ "POST {path}?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
@@ -1142,7 +1307,10 @@ mod tests {
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
- vec![get(&token), post(&token, "down=action-ground&up=table")],
+ vec![
+ get(&token),
+ post(&token, "/command", "down=action-ground&up=table"),
+ ],
);
let choice = server
@@ -1181,7 +1349,7 @@ mod tests {
vec![
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
.to_string(),
- post(&token, "down=action-ground&up=table"),
+ post(&token, "/command", "down=action-ground&up=table"),
],
);
@@ -1213,8 +1381,8 @@ mod tests {
let client = converse(
port,
vec![
- post(&token, "down=seat-1&up=seat-2"),
- post(&token, "down=action-ground&up=table"),
+ post(&token, "/command", "down=seat-1&up=seat-2"),
+ post(&token, "/command", "down=action-ground&up=table"),
],
);
@@ -1277,7 +1445,7 @@ mod tests {
// 3. send exactly what the JavaScript produced — not what this
// test thinks it should have produced
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
- s.write_all(post(&token, &posts[0].body).as_bytes())
+ s.write_all(post(&token, "/command", &posts[0].body).as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
@@ -1327,7 +1495,7 @@ mod tests {
pages.push(page);
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
- s.write_all(post(&tok, "down=action-ground&up=table").as_bytes())
+ s.write_all(post(&tok, "/command", "down=action-ground&up=table").as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs
index 5752c06..c1b0a7c 100644
--- a/tools/cb-play/src/table.rs
+++ b/tools/cb-play/src/table.rs
@@ -270,7 +270,7 @@ fn end_badly(
// nothing to the series and the panel stays absent. Passing an
// empty tally rather than the live one is deliberate: a crashed
// game must not be counted as a played one.
- let _ = s.serve_end(None, &msg, END_LINGER, &MatchTally::default());
+ let _ = s.serve_end(None, None, &msg, END_LINGER, &MatchTally::default());
}
Err(msg)
}
@@ -434,7 +434,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
tally.record(o);
}
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
- end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER, tally)?;
+ end_choice = srv.serve_end(Some(&ended), Some(&game.state), &msg, END_LINGER, tally)?;
}
let scenario = games_ground::record::to_scenario(
diff --git a/trials/2026-08-07-1252.yaml b/trials/2026-08-07-1252.yaml
new file mode 100644
index 0000000..21eda29
--- /dev/null
+++ b/trials/2026-08-07-1252.yaml
@@ -0,0 +1,145 @@
+scenario: ground/cb-play-session
+description: recorded by cb-play (CB-WP-0008 T02)
+covers: []
+provisional: false
+provisional_owner: ''
+provisional_raised: ''
+ruled: ''
+ruled_by: ''
+ruled_note: ''
+encodes_u_item: ''
+seed: 4
+setup:
+ players: 3
+ preset: standard-3p
+ patch: {}
+commands:
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: P2
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 1
+- actor: P3
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 3
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 3
+- actor: P3
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 2
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 3
+- actor: P2
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 3
+- actor: P3
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 3
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: P3
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 4
+- actor: P2
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 4
+- actor: P3
+ cmd: select_action
+ args:
+ action: SUPPORT
+ target: P1
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: respond_to_support
+ args:
+ response: accept_bond
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+expect:
+ events: []
+ state: {}
+ rejects: []
+ state_hash: ba880bbda3730915e6a8dc268b0ad63f848b5ac3e53a21ca6e99d17c33c6527a
diff --git a/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md b/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md
new file mode 100644
index 0000000..249e636
--- /dev/null
+++ b/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md
@@ -0,0 +1,106 @@
+---
+id: CB-WP-0031
+kind: product
+title: "The comment box outlives the game"
+status: done
+---
+
+# Purpose
+
+```
+structural tier S (one channel reopened on an existing page; no new
+ decision, no new dependency, no budget moved)
+chaos d8 = 2 → no override
+declared tier S
+```
+
+**Declaration 2 of chaos window 3**, opened 2026-08-07 by ADR-0017.
+
+## The report
+
+> *"Before we move on, i want to be able to add comments after the game
+> finished."*
+
+## What was actually broken, and it was two things
+
+**The channel closed at the moment it is worth most.** A player who has
+just seen the outcome is the one with something to say — *"so that is why
+that failed"*, *"I never understood what that card did"* — and that is the
+reading the whole trial protocol (GameDesign §5) exists to collect. It was
+collectable at every moment of the game **except** the one after it.
+
+Two independent defects, either of which alone shuts the channel:
+
+| where | what |
+|---|---|
+| `doc::ending` | never rendered the comment box at all |
+| `hotseat::serve_end` | had no `POST /note` arm — a hand-built post fell through to `404 the game is over` |
+
+**Fixing either alone leaves it shut**, which is why the test asserts both
+and why a markup-only test would have passed against a server that still
+refused.
+
+## The thing that needed deciding: what a post-game note is bound to
+
+A note is bound to a position by `state_hash` (ADR-0014 D3), and the
+position is genuinely the final state. But **`round` and `step` say *when
+the player said it***, and a note written after the outcome is on screen
+is not an observation made at the last decision point.
+
+`RoundStep` has an `End` variant — **the last step of a round, not the end
+of the game.** Logging a post-game note under it would file an
+after-the-fact reading as an in-play one: a value that is correct about the
+wrong subject, which is the family ADR-0018 was written for two hours
+earlier.
+
+So `record_note` takes the step **as an argument** rather than reading it
+off the state, and the post-game path passes `"after the end"` — which
+cannot collide with a `Debug` identifier.
+
+## No box where there is nothing to bind to
+
+A game that stopped without a result has no final position. The box is
+**not offered** there, and the page says why. Offering one that would
+answer `409` is a control that exists to be hit.
+
+## Task: reopen the channel
+
+```task
+id: CB-WP-0031-T01
+status: done
+priority: high
+```
+
+**Controls:**
+- **the test fails on each half separately** — mutation-proven: blanking
+ the form gives *"the ending page offered no comment box"*, removing the
+ server arm gives `404`; both green when restored;
+- **a note must not end the session** — every other POST in `serve_end`
+ breaks the loop, and a player must be able to write a second note and
+ then still press `play again`. The test posts a command after the note
+ and requires it to still work;
+- **the redirect carries the token**, which is the defect that twice made
+ a *saved* note read as a failed one;
+- **the log distinguishes when it was written** from the position it binds
+ to.
+
+**Done 2026-08-07.**
+
+`note_form` extracted and shared by both pages — a hand-written twin is
+how one of them quietly stops posting anywhere useful. The placeholder
+differs, and only that: what a player has to say mid-turn and what they
+have to say having seen the result are not the same prompt.
+
+**A test-harness defect worth recording.** The first draft wrote
+`Content-Length: 21` beside a 22-byte body; the server read 21 bytes and
+answered `400 unrecognised field`, which looked exactly like the feature
+being broken. The fix was to derive the length from the body in the shared
+`post` helper — **a hand-written length is a second copy of a fact the
+body already carries**, the same shape as the 62 untagged literals
+`facts-check` counts.
+
+## Not done here
+
+- **Notes are still not shown back to the player.** Neither page echoes
+ what has been written; the trial log is the only place they exist. That
+ is a real gap and it is not this report.