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.