From 4ddff3b17ccc08f9c33ac07cee834ec6955571d8 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 7 Aug 2026 16:09:36 +0200 Subject: [PATCH] CB-WP-0032: the comments in the account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes were written to a file and shown back nowhere — the shape trials.py's own docstring calls this project's signature failure in a new medium. The log now carries the player's comments where they were made. Position is the feature: a remark like "why did that do nothing?" is about the move above it, and collected at the bottom it is a sentence with no subject. round/step cannot order a note against the log because several commands share a step, so the server records how many commands had been played — which it knows exactly — and the page places it there. A comment must not be readable as something the game did. The log is the recorder's vocabulary; notes render in their own block, attributed to the player, quoted. The .note CSS already existed and nothing had ever used it. No column was added to the trial log. trials.py skipped any row that was not five cells, silently, so a sixth column would have made `make trials` report zero notes for every log at once. That latent defect is fixed on its own terms: a wrong column count now raises, and the walk reports the real reason rather than blaming a missing block for every failure. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 2 + crates/cb-render-html/src/doc.rs | 87 +++++++++- crates/cb-render-html/src/lib.rs | 155 ++++++++++++++++-- tools/cb-play/src/hotseat.rs | 60 ++++++- tools/trials.py | 52 +++++- trials/2026-08-07-1558.md | 17 ++ ...-0031-the-comment-box-outlives-the-game.md | 2 + .../CB-WP-0032-the-comments-in-the-account.md | 106 ++++++++++++ 8 files changed, 455 insertions(+), 26 deletions(-) create mode 100644 trials/2026-08-07-1558.md create mode 100644 workplans/CB-WP-0032-the-comments-in-the-account.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c672249..34e6de8 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -38,6 +38,7 @@ | 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 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| workplan | CB-WP-0031 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.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 | @@ -199,3 +200,4 @@ | 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 | +| task | CB-WP-0031-T01 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 993e735..9387227 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -930,7 +930,7 @@ pub fn document( }, seat, may_pass, - &[], + Account::of(&[]), &[], ) } @@ -955,7 +955,7 @@ pub fn document_with_log( to: Endpoints<'_>, seat: Option, may_pass: bool, - log: &[LogLine], + log: Account<'_>, meta: &[String], ) -> String { let (endpoint, note_to) = (to.command, to.note); @@ -1305,13 +1305,81 @@ pub struct LogLine { pub effects: Vec, } +/// One thing the player said, placed in the account (CB-WP-0032). +/// +/// **`after` is a count of commands, not a round.** A note anchored to +/// `round`/`step` cannot be ordered against the log, because several +/// commands share a step and the log is a sequence of commands — placing +/// by round would put a note *somewhere in* the right round, which for a +/// remark about a specific move is the wrong position stated confidently. +/// The server knows exactly how many commands had been played when the +/// note was written, so that is what it records. +#[derive(Debug, Clone)] +pub struct LogNote { + /// Commands played when this was written; `0` is before any move. + pub after: usize, + pub round: u8, + /// Where in the round, or `after the end` for a post-game note. + pub step: String, + pub text: String, +} + +/// The account of the session: what happened, and what the player said +/// about it, in one sequence. +/// +/// **A struct rather than two parameters** because both pages take both +/// and `document_with_log` was already at seven arguments — and because +/// the two are only meaningful together, which is the point of showing +/// them interleaved. +#[derive(Clone, Copy)] +pub struct Account<'a> { + pub lines: &'a [LogLine], + pub notes: &'a [LogNote], +} + +impl<'a> Account<'a> { + /// An account with no commentary — for callers that have no notes. + pub fn of(lines: &'a [LogLine]) -> Self { + Self { lines, notes: &[] } + } +} + /// The game log, newest last, with the empty case spelled out. -fn log_section(s: &mut String, log: &[LogLine]) { +/// +/// **The player's own comments are interleaved** (CB-WP-0032) at the +/// point they were written, because a remark like *"why did that do +/// nothing?"* is about the move above it and loses its subject anywhere +/// else. +/// +/// **They are marked as the player's, and cannot be read as game +/// events.** A note carries no `who`/`what` and renders in its own +/// `.note` block with the player's words in quotes: the log is the +/// recorder's vocabulary — what the scenario file will say — and a +/// comment that looked like a log line would be a sentence the game never +/// produced, sitting in the account of what the game did. +fn log_section(s: &mut String, log: Account<'_>) { + fn note_block(s: &mut String, n: &LogNote) { + let _ = write!( + s, + "
you \u{2014} round {round}, \ + {step}
\u{201c}{text}\u{201d}
", + round = n.round, + step = esc(&n.step), + text = esc(&n.text), + ); + } + let notes_after = |k: usize, s: &mut String| { + for n in log.notes.iter().filter(|n| n.after == k) { + note_block(s, n); + } + }; s.push_str("

log

"); - if log.is_empty() { + if log.lines.is_empty() && log.notes.is_empty() { s.push_str("nothing has happened yet"); } - for line in log { + // Anything said before the first move still belongs in the account. + notes_after(0, s); + for (i, line) in log.lines.iter().enumerate() { let _ = write!( s, "
{who} {what}", @@ -1327,6 +1395,13 @@ fn log_section(s: &mut String, log: &[LogLine]) { } } s.push_str("
"); + notes_after(i + 1, s); + } + // A note can outrun the log: post-game notes are written when every + // command has been played, and one written against a longer log than + // this page has must still appear rather than be silently dropped. + for n in log.notes.iter().filter(|n| n.after > log.lines.len()) { + note_block(s, n); } s.push_str("
"); } @@ -1459,7 +1534,7 @@ pub fn ending( message: &str, endpoint: &str, note_to: Option<&str>, - log: &[LogLine], + log: Account<'_>, series: &[String], ) -> String { let mut s = String::with_capacity(4096); diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index e613a3b..3885e1f 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -587,7 +587,7 @@ mod affordances { mod gamelog { //! CB-WP-0018 T02. - use crate::doc::{document_with_log, LogLine}; + use crate::doc::{document_with_log, Account, LogLine}; use cb_kernel::PlayerId; fn line(effects: &[&str]) -> LogLine { @@ -598,6 +598,104 @@ mod gamelog { } } + fn note(after: usize, text: &str) -> crate::doc::LogNote { + crate::doc::LogNote { + after, + round: 2, + step: "Select".into(), + text: text.into(), + } + } + + /// CB-WP-0032. A comment appears where it was written. + /// + /// **Position is the whole feature.** A remark like *"why did that do + /// nothing?"* is about the move above it; collected at the bottom it + /// is a list of sentences with no subjects. + #[test] + fn comments_appear_in_the_log_where_they_were_written() { + let lines = vec![line(&["e0"]), line(&["e1"]), line(&["e2"])]; + let html = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account { + lines: &lines, + notes: &[ + note(0, "before I moved"), + note(2, "why did that do nothing"), + ], + }, + &[], + ); + let text = crate::text_of(&html); + let at = |needle: &str| { + text.find(needle) + .unwrap_or_else(|| panic!("missing {needle:?}")) + }; + // Written before any move: above the first effect. + assert!(at("before I moved") < at("e0"), "{text}"); + // Written after two commands: after the second, before the third. + assert!(at("e1") < at("why did that do nothing"), "{text}"); + assert!(at("why did that do nothing") < at("e2"), "{text}"); + } + + /// A comment must not be readable as something the game did. + /// + /// The log is the RECORDER's vocabulary — what the scenario file will + /// say. A note styled like a log line would be a sentence the game + /// never produced, sitting in the account of what the game produced, + /// and `ADR-0014 D4` turns on that distinction being visible. + #[test] + fn a_comment_is_not_dressed_as_a_game_event() { + let lines = vec![line(&["e0"])]; + let html = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account { + lines: &lines, + notes: &[note(1, "select_action action=SOLVE")], + }, + &[], + ); + assert!( + html.contains("class=\"note\""), + "the note had no marking of its own" + ); + let text = crate::text_of(&html); + // Attributed to the player, and to a position. + assert!(text.contains("you \u{2014} round 2, Select"), "{text}"); + } + + /// A post-game note is written when every command has been played, so + /// its `after` can exceed a log this page happens to be showing. It + /// must still appear — a dropped comment is the failure the whole + /// note channel exists to avoid. + #[test] + fn a_comment_past_the_end_of_the_log_is_still_shown() { + let html = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account { + lines: &[], + notes: &[note(9, "so that is why it failed")], + }, + &[], + ); + assert!( + crate::text_of(&html).contains("so that is why it failed"), + "a comment was silently dropped" + ); + } + fn page(log: &[LogLine]) -> String { document_with_log( &crate::testfix::view(Some(PlayerId(0))), @@ -605,7 +703,7 @@ mod gamelog { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - log, + Account::of(log), &[], ) } @@ -1133,7 +1231,7 @@ mod notes { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - &[], + crate::doc::Account::of(&[]), &[hostile.to_string()], ); assert!( @@ -1161,7 +1259,7 @@ mod notes { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - &[], + crate::doc::Account::of(&[]), &[], ); assert!( @@ -1189,7 +1287,7 @@ mod two_columns { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - &[], + crate::doc::Account::of(&[]), meta, ) } @@ -1360,7 +1458,14 @@ mod ending_page { use crate::{doc, jsrun}; fn page() -> String { - doc::ending(None, "the game ended", "/command?t=x", None, &[], &[]) + doc::ending( + None, + "the game ended", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + ) } /// The label must say what the control DOES. Asserted on the rendered @@ -1439,7 +1544,14 @@ mod ending_page { #[test] fn click_targets_do_not_wear_the_drag_affordance() { let pages = [ - doc::ending(None, "m", "/command?t=x", None, &[], &[]), + doc::ending( + None, + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + ), doc::document( &crate::testfix::view(Some(PlayerId(0))), &[games_ground::GroundCommand::SelectAction { @@ -1490,7 +1602,14 @@ 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", None, &[], &[])) + crate::text_of(&doc::ending( + Some(v), + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + )) }; assert!( head(&won).contains("game solved"), @@ -1512,7 +1631,7 @@ mod ending_page { "P1 ran out of input", "/command?t=x", None, - &[], + crate::doc::Account::of(&[]), &[], )); assert!(!none.contains("game solved") && !none.contains("game over")); @@ -1526,7 +1645,14 @@ 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", None, &[], &[])); + let text = crate::text_of(&doc::ending( + Some(&v), + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + )); assert!( text.contains("problems solved"), @@ -1549,7 +1675,14 @@ 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", None, &[], &[])); + let text = crate::text_of(&doc::ending( + Some(&v), + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + )); assert!( text.contains("Lower combined Stress"), diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index c27f50d..e2a9e53 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -47,6 +47,14 @@ pub struct Server { #[derive(Debug, Clone)] pub struct TrialNote { pub n: usize, + /// Commands played when this was written (CB-WP-0032), so the page + /// can put the comment where it was made. **Held in memory only.** + /// It is deliberately NOT a column in the trial log: `trials.py` + /// skips any row that is not five cells, silently, so a sixth column + /// would make `make trials` report zero notes for a file full of + /// them — the exact failure its own docstring says it exists to + /// prevent. + pub after: usize, pub round: u8, pub step: String, pub state_hash: String, @@ -146,10 +154,12 @@ impl Server { return Err("no trial log for this session — start with --trial".into()); }; let hash = cb_events::state_hash_hex(state); + let after = self.journal.borrow().len(); let mut log = self.notes.borrow_mut(); let n = log.len() + 1; log.push(TrialNote { n, + after, round: state.round, step: step.to_string(), state_hash: hash[..12].to_string(), @@ -173,6 +183,20 @@ impl Server { self.journal.clone() } + /// The player's own comments, positioned for the log (CB-WP-0032). + fn log_notes(&self) -> Vec { + self.notes + .borrow() + .iter() + .map(|n| cb_render_html::doc::LogNote { + after: n.after, + round: n.round, + step: n.step.clone(), + text: n.text.clone(), + }) + .collect() + } + /// The log as the page renders it, in the recorder's vocabulary. /// /// `record::to_step` is reused rather than phrased afresh: what the @@ -261,7 +285,10 @@ impl Server { }, Some(seat), may_pass, - &self.log_lines(), + cb_render_html::doc::Account { + lines: &self.log_lines(), + notes: &self.log_notes(), + }, // 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. @@ -394,7 +421,10 @@ impl Server { message, &self.guard.endpoint(), note_to.as_deref(), - &self.log_lines(), + cb_render_html::doc::Account { + lines: &self.log_lines(), + notes: &self.log_notes(), + }, &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); @@ -875,6 +905,8 @@ mod tests { // 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"), + // CB-WP-0032: and it comes BACK, in the log. + format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), post(&token, "/command", "down=done&up=done"), ], ); @@ -906,9 +938,20 @@ mod tests { "the redirect dropped the token, which reads as a refusal: {}", replies[1] ); + // CB-WP-0032. Written, then read back on the very next page — + // the round trip, not just the write. + assert!( + replies[2].contains("so that is why it"), + "the note was saved but never shown back: {}", + replies[2] + ); + assert!( + replies[2].contains("class=\"note\""), + "the note was shown without marking it as the player's" + ); // The session survived the note. assert!( - replies[2].contains("closed"), + replies[3].contains("closed"), "the note ended the session, so nothing could follow it: {}", replies[2] ); @@ -932,7 +975,14 @@ mod tests { #[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, &[], &[]); + let page = cb_render_html::doc::ending( + None, + "it broke", + "/c", + None, + cb_render_html::doc::Account::of(&[]), + &[], + ); s.push_str(&page); assert!( !s.contains("id=\"cb-note\""), @@ -1013,7 +1063,7 @@ mod tests { "P1 ran out of input", "/command?t=x", None, - &[], + cb_render_html::doc::Account::of(&[]), &[], ); assert!( diff --git a/tools/trials.py b/tools/trials.py index c173ceb..dc7a888 100644 --- a/tools/trials.py +++ b/tools/trials.py @@ -27,6 +27,11 @@ END = "" # cannot expire ages into an apparent finding. NOTE_EXPIRY_DAYS = 30 +# The trial-log table's columns, named once. `write_trial_log` in +# `hotseat.rs` writes them; this reads them; a row that is not this shape +# is an error rather than a row to skip. +COLUMNS = ("n", "round", "step", "state_hash", "comment") + def parse(text): """Rows between the markers. A log without the block is an error, not @@ -41,9 +46,21 @@ def parse(text): 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": + if cells[0] == "n": continue - rows.append(dict(zip(("n", "round", "step", "state_hash", "comment"), cells))) + # CB-WP-0032. A wrong column count RAISES; it used to `continue`. + # + # The docstring above already says silently reporting zero notes + # for a file full of them is the failure this tool exists to + # prevent -- and a `continue` here did exactly that for every row + # at once. Adding a sixth column to the trial log (which CB-WP-0032 + # considered, and did not do) would have emptied every existing + # log without a word. + if len(cells) != len(COLUMNS): + raise ValueError( + f"trial-log row has {len(cells)} columns, expected {len(COLUMNS)}: {line!r}" + ) + rows.append(dict(zip(COLUMNS, cells))) return rows @@ -53,8 +70,13 @@ def logs(root=TRIALS): 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?") + except ValueError as e: + # The REASON, not a guess at it. This said "no trial-log block + # -- not a trial log?" for every failure, which is now wrong + # for two of them: a malformed row is not a missing block, and + # telling a maintainer to check for the wrong thing costs more + # than saying nothing. + print(f" WARN {os.path.basename(path)}: {e}") continue recording = os.path.splitext(path)[0] + ".yaml" out.append((path, rows, recording if os.path.exists(recording) else None)) @@ -159,6 +181,28 @@ def self_test(): 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))) + # CB-WP-0032. A row of the wrong shape must be LOUD. This used to + # `continue`, so a format change would have reported zero notes for + # every log at once -- the failure the parser's own docstring names. + six = good.replace("| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |", + "| 1 | 3 | Select | abc123def456 | extra | why is SOLVE doing nothing |") + try: + parse(six) + check("a row with the wrong column count is an error", False) + except ValueError as e: + check("a row with the wrong column count is an error", "6 columns" in str(e), + "a silent skip would empty every log at once") + + # And the surrounding walk must report the REASON it failed. + with tempfile.TemporaryDirectory() as d: + with open(os.path.join(d, "2026-08-07-bad.md"), "w") as fh: + fh.write(six) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + logs(d) + check("the walk names the real reason", "6 columns" in buf.getvalue(), + "it used to blame a missing block for every failure") + try: parse("# Trial log\n\nno block here\n") check("a file with no block is an error, not an empty log", False) diff --git a/trials/2026-08-07-1558.md b/trials/2026-08-07-1558.md new file mode 100644 index 0000000..1bfb59b --- /dev/null +++ b/trials/2026-08-07-1558.md @@ -0,0 +1,17 @@ +# Trial log + +What the player said while playing, bound to the position they said it at +(CB-WP-0027, ADR-0014). The recording beside this file is the position; +`state_hash` is what reaches it. + +**These are raw notes and they stay here.** Nothing in this file travels to +`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a +register finding, by a human, with the wording chosen then. + + + +| n | round | step | state_hash | comment | +|---|---|---|---|---| +| 1 | 5 | after the end | 8ade38824ca4 | The game was fine, but i can not the player i bond or not-bond with. We should change that. Having my hand cards above the seats close to the table would feel more natural to me. | + + 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 index 249e636..c681f1c 100644 --- a/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md +++ b/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md @@ -3,6 +3,7 @@ id: CB-WP-0031 kind: product title: "The comment box outlives the game" status: done +state_hub_workstream_id: "7b7450cd-3972-4c78-a53c-fba54b3fc02b" --- # Purpose @@ -69,6 +70,7 @@ answer `409` is a control that exists to be hit. id: CB-WP-0031-T01 status: done priority: high +state_hub_task_id: "6b92d470-b3a2-4cde-a307-d20cb6a45ab4" ``` **Controls:** diff --git a/workplans/CB-WP-0032-the-comments-in-the-account.md b/workplans/CB-WP-0032-the-comments-in-the-account.md new file mode 100644 index 0000000..960e8cd --- /dev/null +++ b/workplans/CB-WP-0032-the-comments-in-the-account.md @@ -0,0 +1,106 @@ +--- +id: CB-WP-0032 +kind: product +title: "The comments in the account" +status: done +--- + +# Purpose + +``` +structural tier S (one existing renderer gains a second input; no new + decision, no dependency, no budget moved) +chaos d8 = 6 → no override +declared tier S +``` + +**Declaration 3 of chaos window 3.** + +## The report + +> *"Commenting after the game is a good practice. We could show the +> comments in the log. I would think that as useful."* + +This closes the gap [CB-WP-0031](CB-WP-0031-the-comment-box-outlives-the-game.md) +named and did not fix: notes were written to a file and **shown back +nowhere**. `trials.py`'s own docstring calls that shape *"this project's +signature failure in a new medium"* (ADR-0014 D6) — and the note channel +had it, in the interface, for as long as it has existed. + +## The decision: what a comment is positioned against + +A note carries `round`, `step` and `state_hash`. **None of them can order +it against the log**, because the log is a sequence of *commands* and +several commands share a step. Positioning by round would put a comment +*somewhere in* the right round — for a remark about a specific move, the +wrong position stated confidently. + +So the server records **how many commands had been played** when the note +was written, which it knows exactly (`journal.len()`), and the page places +it there. `after = 0` is *before anyone moved*, which is a real thing to +have an opinion at. + +## The thing that must not happen + +**A comment must not be readable as something the game did.** The log is +the recorder's vocabulary — `record::to_step`, what the scenario file will +say — so a note styled as a log line would be a sentence the game never +produced, sitting in the account of what the game produced. ADR-0014 D4 +turns on that distinction being visible: raw notes stay in this repo, and +only a human promotes one to a finding. + +Notes render in their own `.note` block, attributed *"you — round N, +step"*, with the words in quotes. **The `.note` CSS already existed and was +never used by anything** — defined for this, and left unwired. + +## What was NOT done, and why it is the interesting half + +**No column was added to the trial log**, though `after` is genuinely the +anchor and persisting it would let a later reader interleave. + +`trials.py` skipped any row that was not five cells — with `continue`, in +silence. A sixth column would have made `make trials` report **zero notes +for every log at once**, which is verbatim the failure the parser's own +docstring says it exists to prevent. `after` is therefore held in memory +for the session and the file format is untouched. + +**The latent defect was fixed on its own terms**: a wrong column count now +raises, and the walk that wraps it reports the *real* reason instead of +blaming a missing block for every failure. + +## Task: show the comments where they were made + +```task +id: CB-WP-0032-T01 +status: done +priority: high +``` + +**Controls:** +- **placement is asserted, not just presence** — mutation-proven: append + the notes at the end instead of interleaving and the test goes red; +- **a comment cannot be read as a game event** — asserted on its own + marking and its attribution; +- **a comment past the end of the log still appears**, because a post-game + note is written when every command has been played and a dropped comment + is the failure the channel exists to avoid; +- **the round trip, not the write** — the `cb-play` test posts a note and + then GETs the page, which is the path a player takes. + +**Done 2026-08-07.** + +`Account { lines, notes }` replaces the bare `&[LogLine]` on both pages — +a struct because `document_with_log` was already at seven arguments, and +because the two are only meaningful together, which is the point. + +`trials.py` hardened as described above, with two new controls: a +wrong-shaped row raises, and the walk names the reason. + +## Not done here + +- **Nothing lets a player edit or delete a comment.** A note is a record + of what they thought at the time and rewriting it would defeat that, + but a typo is a real case and there is no answer for it yet. +- **The ending page shows the same interleaved account**, so a long game + puts the post-game comment at the bottom of a long log. Whether that is + where a reader looks for it is untested.