CB-WP-0033: a game is the unit
Some checks failed
ci / check (push) Failing after 3s

make trials reported "positions unreachable: 4, target 0 — the recording
exists but the position moved". All four were false. A recording holds one
hash, the final state, and reachability asked whether the note's hash was
in that file — so a mid-game note could never match, and a post-game note
from any but the last game could not either. Instance 8 of the ADR-0018
family: vary only WHEN a note was written and the answer flips, with
nothing having moved.

The root cause was not the metric. play again reused state belonging to a
game: it overwrote the previous game's recording (data loss), never
cleared the journal (game 2's log opened with game 1's commands), and so a
note's command index pointed into a recording without those commands.
Fixing reachability alone would have gone green while a session still
destroyed its own evidence.

A note now binds by (game, after) — an index into the recording's own
commands list, which a reader can replay to. The hash keeps a job as the
integrity check at the end of a game, where it can actually fail. Game 1
keeps the path it was given, so GameDesign §5's documented invocation is
unchanged; later games get -2, -3 and nothing is overwritten. Legacy
5-column logs stay readable and are reported as legacy, never as orphans —
an unsubstantiated orphan claim is the defect being fixed.

All three fixes mutation-proven, including at the call site via a real
two-game session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 17:41:11 +02:00
parent 4ddff3b17c
commit edab11c0e4
10 changed files with 909 additions and 41 deletions

View file

@ -39,6 +39,7 @@
| 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 |
| workplan | CB-WP-0032 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.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 |
@ -201,3 +202,4 @@
| 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 |
| task | CB-WP-0032-T01 | done | — | workplans/CB-WP-0032-the-comments-in-the-account.md |

View file

@ -0,0 +1,114 @@
# ADR-0019: a game is the unit, and `play again` was reusing what belongs to one
status: accepted
date: 2026-08-07
decided by: agent, under the standing loop authorization
tier: M (structural M — changes an artifact contract, the recording path,
and the definition of a reported metric). chaos d8 = 7 → no override.
**Declaration 4 of chaos window 3.**
references: [CB-WP-0033](../workplans/CB-WP-0033-a-game-is-the-unit.md),
[ADR-0014](ADR-0014-a-second-channel.md) (the note channel),
[ADR-0018](ADR-0018-a-number-that-does-not-move.md) (instance 8),
[GameDesign.md](../specs/GameDesign.md) §5
## Context
`make trials` reported **positions unreachable: 4, target 0**, with the
explanation *"the recording exists but the position moved."*
**All four were false, and the explanation was false four times.**
A recording carries exactly **one** hash — `expect.state_hash`, the final
state. `reachability()` asked whether a note's hash appeared in that file.
So the answer never depended on any position moving:
| note | outcome | why |
|---|---|---|
| written mid-game | **always orphan** | no intermediate hash is in the file to match |
| written after the last game | ok | it *is* the final hash |
| written after an earlier game | orphan | `play again` overwrote the recording |
Both Aug-7 sessions confirm it. In `2026-08-07-1612`, two post-game notes
with identical round and step: game 1's is *orphan*, game 2's is *ok*,
purely because the file holds game 2.
**Instance 8 of the family in ADR-0018**, and the sensitivity is exact:
vary only *when* a note was written and reachability flips
deterministically, with nothing having moved. The computation
(`hash in file`) was right; the subject — a file with one hash, read as a
file of positions — was not.
## D1 — the root cause is not the metric
Three defects, one cause: **`play again` reuses state that belongs to a
game, not to a session.**
| what | reused | consequence |
|---|---|---|
| the recording path | same file | **game 1's recording is destroyed** |
| the journal | never cleared | game 2's log shows game 1's commands |
| the note's position | counted against the shared journal | the index means nothing |
The metric was reporting the symptom, in the wrong words. **Fixing only
`reachability()` would have made the number go green while a session still
destroyed its own evidence** — the failure the register calls `inert`.
## D2 — the recording path, and what stays unchanged
Game 1 keeps the path it was given. Games 2, 3 … get `-2`, `-3` before the
extension.
**The single-game session is byte-identical to today**, which matters
because GameDesign §5 documents exactly that invocation as the trial
protocol, and this project has already shipped one gate that was written
for a smaller world (CB-EV-0026 §4). A scheme that renamed *every* file
would have made the spec wrong on the day it landed.
**Nothing is overwritten.** A recording is evidence; `play again` silently
deleting the previous game's evidence is a data-loss defect independent of
notes.
## D3 — a note binds to a position by `(game, after)`
`after` is the number of commands played when the note was written, which
is exactly an index into the recording's own `commands:` list. **That is a
binding a reader can act on**: replay the first `after` commands and you
are standing where the player stood.
The hash keeps a job — an **integrity check where it applies**. When
`after` equals the command count, the note's hash must equal
`expect.state_hash`, and a mismatch is a real finding.
**Why not record a hash per command instead?** It would make every
position matchable by string search, and it changes the recording format —
which `record.rs`'s round trip, `edition-check` and any consumer in
`ground-game` all read. Deferred, not refused: if a reader ever needs to
confirm a mid-game position without replaying, that is the trigger.
## D4 — old logs stay readable, and are not called orphans
The trial log gains `game` and `after`. Existing 5-column rows are parsed
as **legacy** and reported as such.
**They are not reported as orphans.** Their positions were never checkable
by anything this repo can now run, and an orphan claim we cannot
substantiate is precisely the false explanation this ADR exists to delete.
A row that is neither 5 nor 7 columns raises (CB-WP-0032).
## Consequences
- `trials.py`'s headline metric changes meaning; the old number was not
measuring reachability and no comparison to it is valid.
- The journal is cleared per game, so the log shows **this** game.
- Notes from earlier games do not appear in the current game's log.
- A multi-game session leaves several recordings where it left one.
## What was rejected
| rejected | why |
|---|---|
| fixing `reachability()` alone | green metric, session still destroys its own recordings |
| a hash per command in the recording | changes a format three consumers read, to save a replay |
| renaming every recording to `-1`, `-2` | makes GameDesign §5's documented invocation wrong for the common case |
| calling legacy rows orphans | an unsubstantiated claim, which is the defect being fixed |
| clearing the notes per game | the trial log is the session's record; only the *log view* is per-game |

View file

@ -41,19 +41,28 @@ pub struct Server {
/// to — a partial write leaves a file that neither a human nor
/// `make trials` can read.
notes: RefCell<Vec<TrialNote>>,
/// Which game of the session is being played, 1-based (ADR-0019 D1).
/// The journal and the recording are per game; the notes and the
/// tally are per session, and conflating the two is what made a
/// second game overwrite the first one's evidence.
game: std::cell::Cell<usize>,
}
/// One thing a player said, and where they said it (ADR-0014 D3).
#[derive(Debug, Clone)]
pub struct TrialNote {
pub n: usize,
/// Which game of the session, 1-based (ADR-0019 D3).
pub game: 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.
/// can put the comment where it was made — and, since ADR-0019 D3,
/// so a reader can reach the position: it is an index into the
/// recording's own `commands:` list.
///
/// **CB-WP-0032 kept this out of the file** because `trials.py`
/// silently skipped any row that was not five cells, so a new column
/// would have emptied every log at once. That parser now raises and
/// accepts both widths, which is what made this column safe to add.
pub after: usize,
pub round: u8,
pub step: String,
@ -61,6 +70,31 @@ pub struct TrialNote {
pub text: String,
}
/// Where game `n`'s recording goes (ADR-0019 D2).
///
/// **Game 1 keeps the path it was given.** GameDesign §5 documents that
/// exact invocation as the trial protocol, and a scheme that renamed
/// every file would have made the spec wrong for the common case on the
/// day it landed. Games 2, 3 … get `-2`, `-3` before the extension.
///
/// Nothing is overwritten: a recording is evidence, and `play again`
/// deleting the previous game's evidence is a data-loss defect on its own.
pub fn game_path(base: &std::path::Path, n: usize) -> std::path::PathBuf {
if n <= 1 {
return base.to_path_buf();
}
let stem = base
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let mut name = format!("{stem}-{n}");
if let Some(ext) = base.extension() {
name.push('.');
name.push_str(&ext.to_string_lossy());
}
base.with_file_name(name)
}
/// Write the trial log: readable Markdown with one machine-readable block.
///
/// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) —
@ -78,7 +112,14 @@ pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<()
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");
// ADR-0019 D3/D4: `game` and `after` are the binding a reader can act
// on -- replay `after` commands of game `game` and you are standing
// where the player stood. `state_hash` stays as an integrity check at
// the end of a game, where it is the one thing the recording carries.
s.push_str(
"| n | game | after | 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
@ -86,8 +127,8 @@ pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<()
let text = note.text.replace(['\n', '\r'], " ").replace('|', "/");
let _ = writeln!(
s,
"| {} | {} | {} | {} | {} |",
note.n, note.round, note.step, note.state_hash, text
"| {} | {} | {} | {} | {} | {} | {} |",
note.n, note.game, note.after, note.round, note.step, note.state_hash, text
);
}
s.push_str("\n<!-- trial-log:end -->\n");
@ -119,6 +160,7 @@ impl Server {
meta: RefCell::new(Vec::new()),
trial: None,
notes: RefCell::new(Vec::new()),
game: std::cell::Cell::new(1),
})
}
@ -155,10 +197,12 @@ impl Server {
};
let hash = cb_events::state_hash_hex(state);
let after = self.journal.borrow().len();
let game = self.game.get();
let mut log = self.notes.borrow_mut();
let n = log.len() + 1;
log.push(TrialNote {
n,
game,
after,
round: state.round,
step: step.to_string(),
@ -168,6 +212,26 @@ impl Server {
write_trial_log(path, &log)
}
/// Begin the next game (ADR-0019 D1).
///
/// **Clearing the journal is the point.** It is shared with the
/// driver and nothing ever emptied it, so a second game's log opened
/// with the first game's commands still in it — and a note's `after`,
/// counted against that journal, indexed into a recording that did
/// not contain those commands.
///
/// The notes are NOT cleared: the trial log is the session's record
/// (ADR-0019 D4). Only the log *view* is per game.
pub fn next_game(&self) {
self.journal.borrow_mut().clear();
self.game.set(self.game.get() + 1);
}
/// Which game is being played, 1-based.
pub fn game(&self) -> usize {
self.game.get()
}
/// 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
@ -185,9 +249,14 @@ impl Server {
/// The player's own comments, positioned for the log (CB-WP-0032).
fn log_notes(&self) -> Vec<cb_render_html::doc::LogNote> {
let game = self.game.get();
self.notes
.borrow()
.iter()
// THIS game's comments. A note from game 1 placed by its
// command index into game 2's log would sit against a move
// nobody made (ADR-0019 D1).
.filter(|n| n.game == game)
.map(|n| cb_render_html::doc::LogNote {
after: n.after,
round: n.round,
@ -994,6 +1063,120 @@ mod tests {
);
}
/// ADR-0019 D2. Game 1 keeps the path it was given.
#[test]
fn only_later_games_get_a_suffixed_recording() {
let base = std::path::Path::new("trials/2026-08-07-x.yaml");
// GameDesign §5 documents this exact invocation; renaming it
// would have made the spec wrong for the common case.
assert_eq!(game_path(base, 1), base);
assert_eq!(
game_path(base, 2),
std::path::Path::new("trials/2026-08-07-x-2.yaml")
);
assert_eq!(
game_path(base, 3),
std::path::Path::new("trials/2026-08-07-x-3.yaml")
);
// Distinct paths are the whole point: nothing may be overwritten.
assert_ne!(game_path(base, 2), game_path(base, 3));
// And the suffix must not COMPOUND. The driver derives every
// game's path from the ORIGINAL, because deriving it from the
// previous game's would put game 3 in `x-2-3.yaml`.
assert_eq!(
game_path(&game_path(base, 2), 3),
std::path::Path::new("trials/2026-08-07-x-2-3.yaml"),
"this is the shape the driver must avoid, and does by keeping the base"
);
}
/// ADR-0019 D1. The journal belongs to a GAME.
///
/// **Nothing ever cleared it.** It is shared with the driver and
/// lives on the `Server`, which outlives every game, so a second
/// game's log opened with the first game's commands still in it — and
/// a note's `after`, counted against that journal, indexed into a
/// recording that never contained those commands.
#[test]
fn a_second_game_does_not_inherit_the_first_ones_log() {
let server = Server::bind(0).expect("bind");
let j = server.journal();
j.borrow_mut().push(games_ground::bot::Applied {
actor: cb_kernel::Actor::System,
command: GroundCommand::EndRound,
events: vec![],
});
assert_eq!(server.game(), 1);
assert_eq!(server.journal().borrow().len(), 1);
server.next_game();
assert_eq!(server.game(), 2);
assert!(
server.journal().borrow().is_empty(),
"game 2 opened holding game 1's commands"
);
}
/// A comment belongs to the game it was made in (ADR-0019 D1).
///
/// Placed by command index into a LATER game's log, a note from game 1
/// would sit against a move nobody made — a position stated
/// confidently and wrong, which is the family ADR-0018 names.
#[test]
fn a_comment_from_an_earlier_game_is_not_shown_in_a_later_one() {
let dir = std::env::temp_dir().join(format!("cb-g-{}", std::process::id()));
let server = Server::bind(0)
.expect("bind")
.with_trial(Some(dir.join("t.md")));
let st = state();
server
.record_note(
&st,
"Select",
&Note {
text: "in game one".into(),
},
)
.expect("note 1");
assert_eq!(server.log_notes().len(), 1, "its own game shows it");
server.next_game();
assert!(
server.log_notes().is_empty(),
"game 1's comment leaked into game 2's log"
);
server
.record_note(
&st,
"Select",
&Note {
text: "in game two".into(),
},
)
.expect("note 2");
let shown = server.log_notes();
assert_eq!(shown.len(), 1);
assert_eq!(shown[0].text, "in game two");
// But the trial log keeps BOTH: it is the session's record, and
// the game column is what tells them apart (ADR-0019 D4).
let written = std::fs::read_to_string(dir.join("t.md")).expect("trial log");
assert!(written.contains("in game one"), "the session lost a note");
assert!(written.contains("in game two"));
assert!(
written.contains("| 1 | 1 |"),
"game column missing: {written}"
);
assert!(
written.contains("| 2 | 2 |"),
"second note not in game 2: {written}"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// **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
@ -1002,6 +1185,13 @@ mod tests {
fn play_again_deals_a_second_game() {
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let out = SharedOut(buf.clone());
// ADR-0019 D2: a real recording, so this covers the CALL SITE.
// The unit test proves `game_path` names files apart; only this
// proves the driver uses it, which is where the data loss was.
let dir = std::env::temp_dir().join(format!("cb-again-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("tmp");
let rec = dir.join("s.yaml");
let rec_for_thread = rec.clone();
let game = std::thread::spawn(move || {
crate::table::play(
&crate::table::Config {
@ -1010,7 +1200,7 @@ mod tests {
human_seats: vec![0],
bot: "random".into(),
replay_dir: None,
record: None,
record: Some(rec_for_thread),
serve: Some(0),
trial: None,
mode: games_ground::ScoringMode::SharedGround,
@ -1052,6 +1242,19 @@ mod tests {
.collect();
assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len());
assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game");
// ADR-0019 D2. BOTH recordings survive. `play again` used to
// write game 2 over game 1's file, destroying the evidence a
// trial exists to produce -- and every note bound to it.
let second = dir.join("s-2.yaml");
assert!(rec.exists(), "game 1's recording is gone");
assert!(second.exists(), "game 2 was never recorded separately");
assert_ne!(
std::fs::read_to_string(&rec).expect("g1"),
std::fs::read_to_string(&second).expect("g2"),
"the two recordings hold the same game"
);
let _ = std::fs::remove_dir_all(&dir);
}
/// A game that ended badly must say so rather than draw a table for a

View file

@ -315,6 +315,10 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
// point — it is declared here, beside the listener and the seed,
// because those are the other two things that survive `play again`.
let mut tally = MatchTally::default();
// The path the caller gave us, kept UNCHANGED. Deriving the next
// game's path from the previous game's would compound the suffix --
// game 3 would land in `x-2-3.yaml`.
let record_base = config.record.clone();
loop {
// CB-WP-0027 T02: the panel shows the series *during* the next
// game, not only after it.
@ -326,6 +330,19 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
return Ok(summary);
}
cfg.seed = cfg.seed.wrapping_add(1);
// ADR-0019 D1. The journal and the recording belong to a GAME;
// the notes and the tally belong to the session. Nothing advanced
// the first pair, so the second game opened with the first one's
// log still in it and then overwrote its recording.
if let Some(s) = &server {
s.next_game();
}
// ADR-0019 D2. The server holds which game this is; asking it
// keeps ONE counter rather than a second copy here that could
// disagree with the one the notes are stamped with.
if let (Some(s), Some(base)) = (&server, &record_base) {
cfg.record = Some(crate::hotseat::game_path(base, s.game()));
}
}
}

View file

@ -30,7 +30,13 @@ 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")
COLUMNS = ("n", "game", "after", "round", "step", "state_hash", "comment")
# ADR-0019 D4. Logs written before the binding was fixed. They are parsed
# and reported, and they are NOT called orphans: their positions were
# never checkable by anything this repo can run, and an unsubstantiated
# orphan claim is the exact defect ADR-0019 exists to delete.
LEGACY_COLUMNS = ("n", "round", "step", "state_hash", "comment")
def parse(text):
@ -56,14 +62,43 @@ def parse(text):
# 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):
if len(cells) == len(COLUMNS):
rows.append(dict(zip(COLUMNS, cells)))
elif len(cells) == len(LEGACY_COLUMNS):
row = dict(zip(LEGACY_COLUMNS, cells))
row["legacy"] = True
rows.append(row)
else:
raise ValueError(
f"trial-log row has {len(cells)} columns, expected {len(COLUMNS)}: {line!r}"
f"trial-log row has {len(cells)} columns, expected "
f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}"
)
rows.append(dict(zip(COLUMNS, cells)))
return rows
def recording_for(md_path, row):
"""The recording holding this note's game (ADR-0019 D2).
Game 1 keeps the base path; later games carry `-2`, `-3`. A legacy row
names no game, so it gets the base path and is judged as legacy.
"""
stem = os.path.splitext(md_path)[0]
n = int(row.get("game", 1) or 1)
path = f"{stem}.yaml" if n <= 1 else f"{stem}-{n}.yaml"
return path if os.path.exists(path) else None
def command_count(recording):
"""How many commands the recording holds — the range a note's `after`
must fall inside. Counted from the `commands:` block, because that is
the list `after` indexes into."""
text = open(recording).read()
if "commands:" not in text:
return 0
block = text.split("commands:", 1)[1].split("\nexpect:", 1)[0]
return sum(1 for line in block.splitlines() if line.startswith("- actor:"))
def logs(root=TRIALS):
"""Every trial log, with its recording beside it if there is one."""
out = []
@ -98,9 +133,22 @@ def reachability(row, recording):
worth knowing, by the same reasoning that rewrote `gr-e01` rather than
retiring it.
"""
if row.get("legacy"):
return "legacy"
if not recording:
return "pending"
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
after = int(row["after"])
n = command_count(recording)
if after > n:
# The note points past the end of its own game.
return "orphan"
if after == n:
# The one position the recording states outright. A mismatch here
# is real: the note claims the end of a game the recording does
# not agree with.
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
# Reachable by replaying `after` commands of this recording.
return "ok"
def age_days(path, today):
@ -125,32 +173,41 @@ def report(root=TRIALS, today=None):
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:
total, orphans, pending, expired, legacy = 0, 0, 0, 0, 0
for path, rows, _ 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 ''})")
print(f" {name} ({len(rows)} note(s), {age}d)")
for r in rows:
total += 1
state = reachability(r, recording)
# ADR-0019 D2: each note names its own game, and each game has
# its own recording. One recording per FILE was the assumption
# that made `play again` overwrite the evidence.
rec = recording_for(path, r)
state = reachability(r, rec)
orphans += state == "orphan"
pending += state == "pending"
legacy += state == "legacy"
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]}")
mark = {"orphan": "orphan", "pending": " .. ",
"legacy": "legacy", "ok": " "}[state]
where = f"g{r.get('game', '?')}+{r.get('after', '?')}"
print(f" {mark} {where:<7} r{r['round']:<2} {r['step']:<14} "
f"{r['comment'][:60]}")
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 ""))
+ (" <-- a note points past the end of its own game" if orphans else ""))
if pending:
print(f" awaiting a recording {pending}"
" (normal during a live session; the recording lands at game end)")
if legacy:
print(f" legacy binding {legacy}"
" (written before ADR-0019; position not checkable, NOT orphaned)")
print(f" notes past {NOTE_EXPIRY_DAYS} days {expired} target 0")
if total and not orphans:
if total and not orphans and not legacy:
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"
@ -174,18 +231,37 @@ def self_test():
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")
def log_of(*rows):
head = ("| n | game | after | round | step | state_hash | comment |\n"
"|---|---|---|---|---|---|---|\n")
return f"# Trial log\n\n{BEGIN}\n\n{head}" + "".join(rows) + f"\n{END}\n"
good = log_of("| 1 | 1 | 4 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n")
legacy = (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")
def recording(commands, final_hash):
"""A recording with `commands` commands and one final hash — the
shape ADR-0019 D3 reads: a list to index into, plus the one
position the file states outright."""
body = "commands:\n"
for _ in range(commands):
body += "- actor: P1\n cmd: select_action\n args: {}\n"
return body + f"expect:\n state_hash: {final_hash}\n"
check("a trial log parses", len(parse(good)) == 1)
check("a legacy 5-column log still parses", len(parse(legacy)) == 1,
"ADR-0019 D4 \u2014 old logs stay readable")
check("a legacy row is MARKED legacy", parse(legacy)[0].get("legacy") is True)
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 |")
# Six columns is neither the current shape (7) nor the legacy one (5),
# so it must RAISE rather than be skipped.
six = log_of("| 1 | 1 | 4 | 3 | Select | abc123def456 |\n")
try:
parse(six)
check("a row with the wrong column count is an error", False)
@ -212,21 +288,55 @@ def self_test():
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)
row = parse(good)[0] # game 1, after 4, hash abc123def456
check("a note with no recording is PENDING, not orphaned",
reachability(rows[0], None) == "pending",
reachability(row, 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",
# ADR-0019 D3. A MID-GAME note is reachable by replaying `after`
# commands. Under the old check this case was IMPOSSIBLE to pass:
# the recording holds one hash, so every mid-game note was an
# orphan whatever had happened.
open(rec, "w").write(recording(10, "zzz"))
check("a mid-game note inside its game is reachable",
reachability(row, rec) == "ok",
"the old check called every one of these an orphan")
# And it can still say NO: past the end of its own game.
open(rec, "w").write(recording(2, "zzz"))
check("a note past the end of its game is an orphan",
reachability(row, rec) == "orphan",
"without this the metric cannot go red")
# At the end, the hash is the integrity check — and it can fail.
end_row = parse(log_of(
"| 1 | 1 | 4 | 3 | after the end | abc123def456 | done |\n"))[0]
open(rec, "w").write(recording(4, "abc123def456"))
check("an end-of-game note agreeing with the recording is reachable",
reachability(end_row, rec) == "ok")
open(rec, "w").write(recording(4, "something-else"))
check("an end-of-game note whose hash disagrees is an orphan",
reachability(end_row, rec) == "orphan",
"the position moved \u2014 the case the flag exists for")
# A legacy row is never called an orphan (ADR-0019 D4).
open(rec, "w").write(recording(2, "no-match"))
check("a legacy row is LEGACY, never orphan",
reachability(parse(legacy)[0], rec) == "legacy",
"an unsubstantiated orphan claim is the defect being fixed")
# ADR-0019 D2: game 2 reads its OWN recording.
open(os.path.join(d, "g.md"), "w").write("x")
open(os.path.join(d, "g.yaml"), "w").write(recording(1, "one"))
open(os.path.join(d, "g-2.yaml"), "w").write(recording(9, "two"))
g2 = parse(log_of("| 1 | 2 | 5 | 1 | Select | two | second game |\n"))[0]
check("a note from game 2 is judged against game 2's recording",
recording_for(os.path.join(d, "g.md"), g2).endswith("g-2.yaml")
and reachability(g2, recording_for(os.path.join(d, "g.md"), g2)) == "ok",
"one recording per FILE is what let play again overwrite it")
# THE control design-baseline.py lacked: exercise the reporting
# path and assert on what it printed.
buf = io.StringIO()

153
trials/2026-08-07-1558.yaml Normal file
View file

@ -0,0 +1,153 @@
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: 1
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: SOLVE
problem: 1
- 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: INVESTIGATE
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: SOLVE
problem: 3
- actor: P2
cmd: select_action
args:
action: INVESTIGATE
problem: 4
- 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: SUPPORT
target: P2
- actor: P2
cmd: select_action
args:
action: SUPPORT
target: P1
- actor: P3
cmd: select_action
args:
action: SOLVE
problem: 4
- actor: SYSTEM
cmd: reveal
args: {}
- actor: P1
cmd: respond_to_support
args:
response: accept_bond
- actor: P2
cmd: respond_to_support
args:
response: accept_bond
- actor: SYSTEM
cmd: resolve
args: {}
- actor: SYSTEM
cmd: end_round
args: {}
- actor: P1
cmd: select_action
args:
action: SUPPORT
target: P3
- actor: P2
cmd: select_action
args:
action: SUPPORT
target: P1
- actor: P3
cmd: select_action
args:
action: SUPPORT
target: P1
- actor: SYSTEM
cmd: reveal
args: {}
- actor: P3
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: 8ade38824ca4853ac51fa8156673098ab2975a92b0db75783b0cd68a267ec30e

19
trials/2026-08-07-1612.md Normal file
View file

@ -0,0 +1,19 @@
# 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.
<!-- trial-log:begin -->
| n | round | step | state_hash | comment |
|---|---|---|---|---|
| 1 | 5 | after the end | 15e28280a256 | We solved the game. The visualization does not quite show the other players actions. I need to look at the log for that. And i did not comment during the game. Will try that next. |
| 2 | 2 | Select | 37ca932d3a1e | Investigate is most often my first move. |
| 3 | 5 | after the end | 08eae77fe37f | This worked nicely. But i still cant see the player i am bonding with. |
<!-- trial-log:end -->

149
trials/2026-08-07-1612.yaml Normal file
View file

@ -0,0 +1,149 @@
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: 2
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: SOLVE
problem: 1
- 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: 2
- 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: 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: 3
- actor: P2
cmd: select_action
args:
action: SOLVE
problem: 4
- actor: P3
cmd: select_action
args:
action: SOLVE
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: SUPPORT
target: P2
- actor: P2
cmd: select_action
args:
action: SUPPORT
target: P1
- 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: P2
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: 08eae77fe37fd575d4bacbf758ce5fcb54cbdac07e1549237dc3ae38023bce9c

View file

@ -3,6 +3,7 @@ id: CB-WP-0032
kind: product
title: "The comments in the account"
status: done
state_hub_workstream_id: "71d744f9-7e40-49c3-994e-f3fb007a1430"
---
# Purpose
@ -74,6 +75,7 @@ blaming a missing block for every failure.
id: CB-WP-0032-T01
status: done
priority: high
state_hub_task_id: "31f60440-e049-45f7-99fa-87c01c00adb7"
```
**Controls:**

View file

@ -0,0 +1,99 @@
---
id: CB-WP-0033
kind: product
title: "A game is the unit"
status: done
---
# Purpose
```
structural tier M (changes an artifact contract -- the recording path --
and the definition of a reported metric)
chaos d8 = 7 → no override
declared tier M
```
**Declaration 4 of chaos window 3.**
## The report
Reviewing the maintainer's six trial notes, `make trials` said
**positions unreachable: 4, target 0 — "the recording exists but the
position moved."**
**All four were false.** A recording holds exactly one hash, the final
state; `reachability()` asked whether the note's hash was in that file. So
a mid-game note could never match, and a post-game note from any but the
last game could not either.
**Instance 8 of the ADR-0018 family**, with an exact sensitivity: vary
only *when* the note was written and the answer flips deterministically,
with nothing having moved.
## The root cause was not the metric
Three defects, one cause: **`play again` reused state that belongs to a
game.**
| reused | consequence |
|---|---|
| the recording path | **game 1's recording destroyed** — data loss |
| the journal (never cleared) | game 2's log opened with game 1's commands |
| the note's `after`, counted on that journal | indexed a recording without those commands |
**Fixing `reachability()` alone would have turned the number green while a
session still destroyed its own evidence.** [ADR-0019](../decisions/ADR-0019-a-game-is-the-unit.md).
## Task: a game is the unit
```task
id: CB-WP-0033-T01
status: done
priority: high
```
**Controls:**
- **each fix fails on its own defect**, by mutation, or it is not tested;
- **the metric can go red** — a check reporting 0 that cannot report
anything else is decoration (ADR-0006 D3);
- **old logs stay readable and are not called orphans**;
- **the single-game invocation GameDesign §5 documents is unchanged.**
**Done 2026-08-07.** All three mutation-proven:
| mutation | what went red |
|---|---|
| `next_game` stops clearing the journal | *"game 2 opened holding game 1's commands"* |
| notes not filtered by game | *"game 1's comment leaked into game 2's log"* |
| driver stops re-pathing the recording | *"game 2 was never recorded separately"* |
The third is the important one: it runs the **real two-game session**, so
it covers the call site rather than the helper. The unit test proves
`game_path` names files apart; only the integration test proves the driver
uses it, and the driver was where the data loss lived.
**Two defects were introduced and caught while writing this.**
- `cfg.record` was re-derived from the *previous game's* path, so game 3
would have landed in `x-2-3.yaml`. Fixed by keeping the caller's base
untouched, **and the compounding shape is now pinned by an assertion**
so the fix cannot silently rot.
- `Server::game()` was dead code, which `-D warnings` caught. Rather than
`#[allow]`, the driver now *asks the server* which game it is — removing
a second counter that could have disagreed with the one stamped on the
notes.
**The metric now reports honestly**: 0 unreachable, 6 legacy, and it says
what legacy means. `trials.py` gained six controls, including the two that
were previously **impossible to pass** — a mid-game note being reachable,
and a note from game 2 being judged against game 2's recording.
## Not done here
- **No hash per command in the recording** (ADR-0019 D3). A mid-game
position is confirmed by replaying to it, not by string search. The
trigger for revisiting is a reader who needs the position without a
replay.
- **The six existing notes stay legacy forever.** Their positions were
never checkable; nothing is gained by inventing bindings for them.