diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs
index 4610b66..bb168b0 100644
--- a/crates/cb-render-html/src/doc.rs
+++ b/crates/cb-render-html/src/doc.rs
@@ -1306,6 +1306,25 @@ fn body(s: &mut String, view: &GroundView) {
s.push_str("
no problems in play
");
}
s.push_str(&table_svg(view));
+ // CB-WP-0046: the placement is legible, the RULE it encodes was not.
+ //
+ // Shown only when a non-global scope is actually on the table — under
+ // the baseline every Problem is everyone's, and an explanation of a
+ // distinction that is not in play is noise. Same discipline as the
+ // GROUND modes: at the thing it explains, and closable.
+ if view.problem_markers.values().any(|m| {
+ m.scope
+ .is_some_and(|x| x != games_ground::edition::StressScope::Global)
+ }) {
+ if let Some(rule) = stress_scope_rule_text() {
+ let _ = write!(
+ s,
+ "what a Problem\u{2019}s scope does\
+
{} ",
+ esc(&rule)
+ );
+ }
+ }
s.push_str("seats
");
for (id, p) in &view.players {
@@ -1490,6 +1509,20 @@ fn ground_modes_text() -> Option {
.map(|c| c.rules_text)
}
+/// What a scope DOES, in the edition's words (CB-WP-0046).
+///
+/// CB-WP-0045 left this open and gave the wrong reason: *"the scope rule
+/// is ours (a Variant), so there is no vendored sentence to render."* The
+/// H2 package ships `Rules_Text.csv` and its `Problems / Stress scope`
+/// passage is exactly the rule. Nothing read the file.
+///
+/// **The placement was legible and the rule it encodes was not** — a
+/// player could see a card sitting on their Bond lines and have no way to
+/// learn that its Stress presses everyone on those lines.
+fn stress_scope_rule_text() -> Option {
+ games_ground::edition::stress_scope_rule()
+}
+
/// A GROUND sub-choice, in words, naming whoever it touches.
fn ground_choice_label(c: &games_ground::GroundChoice) -> String {
use games_ground::GroundChoice as G;
diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs
index 02b60de..7e316f4 100644
--- a/crates/cb-render-html/src/lib.rs
+++ b/crates/cb-render-html/src/lib.rs
@@ -697,6 +697,65 @@ mod gamelog {
}
}
+ /// **A scope says what it does** (CB-WP-0046).
+ ///
+ /// CB-WP-0045 shipped the placement and recorded the gap with the
+ /// wrong reason — that scoping is our Variant so no printed sentence
+ /// exists. `Rules_Text.csv` was in the H2 package the whole time and
+ /// nothing read it. A player could see a card on their Bond lines
+ /// and have no way to learn what that meant.
+ #[test]
+ fn a_scoped_table_says_what_a_scope_does() {
+ let mut v = crate::testfix::view(Some(PlayerId(0)));
+ v.variant = games_ground::Variant::H2ScopedProblemStress;
+ // One card that is not everyone's — the condition for the rule
+ // to be worth stating at all.
+ if let Some(m) = v.problem_markers.values_mut().next() {
+ m.scope = Some(games_ground::edition::StressScope::Bond);
+ m.owner = Some(PlayerId(1));
+ }
+ let text = crate::text_of(&crate::doc::document(
+ &v,
+ &[],
+ "/c",
+ Some(PlayerId(0)),
+ false,
+ ));
+ assert!(
+ text.contains("scope does"),
+ "Problems are placed by scope and nothing says what a scope does"
+ );
+ // The EDITION's sentence. A paraphrase of ours would drift from
+ // the rule the kernel implements (ADR-0015).
+ let rule = games_ground::edition::stress_scope_rule()
+ .expect("h2 Rules_Text.csv carries the scope rule");
+ let head: String = rule.chars().take(60).collect();
+ assert!(
+ text.contains(&head),
+ "the scope explanation is not the edition's own words: {head:?}"
+ );
+ }
+
+ /// The baseline is not told about a distinction it does not have.
+ #[test]
+ fn an_unscoped_table_does_not_explain_scopes() {
+ let mut v = crate::testfix::view(Some(PlayerId(0)));
+ for m in v.problem_markers.values_mut() {
+ m.scope = Some(games_ground::edition::StressScope::Global);
+ }
+ let text = crate::text_of(&crate::doc::document(
+ &v,
+ &[],
+ "/c",
+ Some(PlayerId(0)),
+ false,
+ ));
+ assert!(
+ !text.contains("scope does"),
+ "the baseline table explains a scope distinction that is not in play"
+ );
+ }
+
/// **The number is on the card** (CB-WP-0045).
///
/// Every move label says "Problem 7"; nothing on the table said which
diff --git a/games/ground/src/edition.rs b/games/ground/src/edition.rs
index 614d442..c942db7 100644
--- a/games/ground/src/edition.rs
+++ b/games/ground/src/edition.rs
@@ -82,6 +82,16 @@ const GLOSSARY_CSV: &str = include_str!("../../../editions/ground-darvo-r0/Gloss
/// H2's Problems table (CB-WP-0042). r0's with one column added.
const H2_PROBLEMS_CSV: &str =
include_str!("../../../editions/experiments/h2-scoped-problem-stress/Problems.csv");
+/// H2's player-facing rulebook. **It was here all along** (CB-WP-0046).
+///
+/// CB-WP-0045 recorded that nothing explains what a *scope does* and
+/// judged it a `ground-game` question, on the reasoning that scoping is
+/// clay-borg's Variant and so has no printed sentence. That was wrong:
+/// the package ships `Rules_Text.csv`, and row 20 is the rule in the
+/// edition's own words. **We had never read the file** — the third time
+/// this pass that vendored text could not reach a player (F18).
+const H2_RULES_TEXT_CSV: &str =
+ include_str!("../../../editions/experiments/h2-scoped-problem-stress/Rules_Text.csv");
/// A vendored CSV, parsed into rows addressable by column name.
///
@@ -562,6 +572,59 @@ pub fn stress_scopes(scenario_id: &str) -> Result, Str
Ok(out)
}
+/// One passage of the variant's rulebook (CB-WP-0046).
+pub struct RulesPassage {
+ pub order: u32,
+ pub section: String,
+ pub heading: String,
+ pub body: String,
+}
+
+/// H2's rulebook, in `Rules_Text.csv` order.
+///
+/// Returned whole rather than as a lookup by heading: a caller that wants
+/// the scope rule asks for its section, and the ordering is the
+/// edition's, not one we impose.
+pub fn h2_rules_text() -> Result, String> {
+ let t = Table::parse(H2_RULES_TEXT_CSV, "h2/Rules_Text.csv")?;
+ let mut out = Vec::new();
+ for row in &t.rows {
+ out.push(RulesPassage {
+ order: t
+ .get(row, "order")?
+ .trim()
+ .parse()
+ .map_err(|_| "order is not a number".to_string())?,
+ section: t.get(row, "section")?.to_string(),
+ heading: t.get(row, "heading")?.to_string(),
+ body: t.get(row, "body")?.to_string(),
+ });
+ }
+ if out.is_empty() {
+ return Err("h2/Rules_Text.csv has no rows".into());
+ }
+ out.sort_by_key(|p| p.order);
+ Ok(out)
+}
+
+/// What a `stress_scope` DOES, in the edition's words (CB-WP-0046).
+///
+/// The passage headed *Stress scope* in the `Problems` section. Matched
+/// on the heading rather than pinned to row 20, because a row number is
+/// a property of today's file and the heading is a property of the rule.
+/// **`None` if the edition stops carrying it** — an absent explanation is
+/// reported as absent, never replaced by ours (ADR-0018).
+pub fn stress_scope_rule() -> Option {
+ h2_rules_text()
+ .ok()?
+ .into_iter()
+ .find(|p| {
+ p.section.eq_ignore_ascii_case("Problems")
+ && p.heading.to_lowercase().contains("stress scope")
+ })
+ .map(|p| p.body)
+}
+
/// The stress gate, printed on every player mat (CB-WP-0037 T03).
///
/// **A mat is mostly ornamentation with one rule on it.** Symbol, colour
diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md
index bc68d5d..7e5c81c 100644
--- a/specs/FindingRegister.md
+++ b/specs/FindingRegister.md
@@ -53,9 +53,30 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
| F21 | degenerate | note | — | — | 2026-08-06 | clay-borg |
| F23 | inconsistent | applied | decisions/ADR-0017-chaos-window-2-verdict.md | counterexample | 2026-08-07 | clay-borg |
| F22 | underdetermined | withdrawn | games_ground::edition::supply_tests::play_never_exceeds_the_components_the_box_holds | counterexample | 2026-08-07 | clay-borg |
+| F26 | inert | raised | `crates/cb-render-html/src/lib.rs::a_scoped_table_says_what_a_scope_does` | counterexample | 2026-08-08 | ground-game |
+- **F26 — a package that adds a FILE is invisible, where a package that
+ adds a column is not.** `h2-scoped-problem-stress` ships
+ `Rules_Text.csv` — twenty-two passages of player-facing rules, including
+ the one passage that says what a `stress_scope` does. No reader in
+ clay-borg mentioned the file, so `edition-check` could not call it stale
+ (a file nothing reads is not out of date, it is unseen) and no gate
+ could call it unread. The rule the table's whole layout encodes sat in
+ the repo, reachable by no player, for the entire measured life of H2.
+
+ **Inert rather than underdetermined**: nothing is ambiguous about the
+ rule and the engine implements it correctly — the text simply could not
+ fire. Third instance of F18's shape and the first where the unit is a
+ file, which is why it is filed separately instead of folded in.
+
+ Raised against `ground-game` because the actionable half is theirs: a
+ package's manifest should name its own files, so that a consumer reading
+ none of them is a detectable state. clay-borg now reads the one passage
+ it needed (CB-WP-0046); the other twenty-one remain unread and that is
+ the standing evidence.
+
- **F11 — SOLVE offered where it cannot act.** Offered on a face-down
Problem, or with no matching suit in hand; inert every time. Ruled
GROUND-WP-0002 T02, implemented CB-WP-0023 as GR-P05. `applied` — the
diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs
index a9dc3d1..029e216 100644
--- a/tools/cb-play/src/hotseat.rs
+++ b/tools/cb-play/src/hotseat.rs
@@ -100,7 +100,11 @@ pub fn game_path(base: &std::path::Path, n: usize) -> std::path::PathBuf {
/// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) —
/// a table between HTML-comment markers, so a human reads the file and a
/// tool reads the block, and neither needs the other's cooperation.
-pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> {
+pub fn write_trial_log(
+ path: &std::path::Path,
+ notes: &[TrialNote],
+ variant: &str,
+) -> Result<(), String> {
let mut s = String::new();
s.push_str("# Trial log\n\n");
s.push_str(
@@ -111,7 +115,19 @@ pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<()
`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a\n\
register finding, by a human, with the wording chosen then.\n\n",
);
- s.push_str("\n\n");
+ // CB-WP-0046: WHICH RULES THIS SESSION PLAYED.
+ //
+ // Without it a baseline note and an H2 note are indistinguishable,
+ // which stopped being survivable the moment both were being played
+ // in the same week. It rides the marker rather than becoming an
+ // eighth column because the variant is a property of the SESSION,
+ // and a per-note column would repeat one fact on every line.
+ //
+ // Read off the STATE, not off the command line: `--variant h2` with
+ // a bare `state.variant = v` assignment leaves H2 inert, and a log
+ // stamped from the flag would have recorded H2 for a session that
+ // played the baseline. This records what actually ran.
+ let _ = write!(s, "\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
@@ -209,7 +225,7 @@ impl Server {
state_hash: hash[..12].to_string(),
text: note.text.clone(),
});
- write_trial_log(path, &log)
+ write_trial_log(path, &log, state.variant.id())
}
/// Begin the next game (ADR-0019 D1).
@@ -968,6 +984,63 @@ mod tests {
/// 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.
+ /// **The log says which rules the session played** (CB-WP-0046).
+ ///
+ /// Without it a baseline note and an H2 note are indistinguishable,
+ /// which is how *"i did not notice any difference"* stays unreadable
+ /// after the fact — you cannot tell whether it is a finding about H2
+ /// or a note about a session that never ran it.
+ #[test]
+ fn the_trial_log_records_which_rules_were_played() {
+ let dir = std::env::temp_dir().join(format!("cb-trial-var-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("trial.md");
+ let note = TrialNote {
+ n: 1,
+ game: 1,
+ after: 4,
+ round: 3,
+ step: "Select".into(),
+ state_hash: "abc123def456".into(),
+ text: "it felt harder".into(),
+ };
+ write_trial_log(&path, &[note], "h2-scoped-problem-stress").unwrap();
+ let written = std::fs::read_to_string(&path).unwrap();
+ assert!(
+ written.contains(""),
+ "the log does not say which rules were played:\n{written}"
+ );
+ // **Not an eighth column.** The variant is a property of the
+ // session; repeating it per row would widen a table whose parser
+ // treats an unexpected width as an error.
+ let row = written
+ .lines()
+ .find(|l| l.starts_with("| 1 |"))
+ .expect("the note row");
+ assert_eq!(
+ row.matches('|').count() - 1,
+ 7,
+ "the table gained a column: {row}"
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// The stamp is read off the STATE, never off the command line.
+ ///
+ /// Three call sites once set `state.variant = v` directly instead of
+ /// `with_variant()`, which renders H2 inert. A log stamped from the
+ /// `--variant` flag would have recorded `h2` for a session that
+ /// played the baseline — a false provenance on real player words,
+ /// which is worse than no provenance.
+ #[test]
+ fn the_stamp_names_the_rules_that_actually_ran() {
+ let src = include_str!("hotseat.rs");
+ assert!(
+ src.contains("write_trial_log(path, &log, state.variant.id())"),
+ "the trial log is stamped from something other than the state"
+ );
+ }
+
#[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()));
diff --git a/tools/trials.py b/tools/trials.py
index e6cf8b3..3164e02 100644
--- a/tools/trials.py
+++ b/tools/trials.py
@@ -15,14 +15,27 @@ reuses `FindingRegister.md`'s idiom: a table between HTML-comment markers
inside a readable Markdown file.
"""
-import os, re, sys, glob, datetime
+import os
+import re, sys, glob, datetime
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRIALS = os.path.join(ROOT, "trials")
-BEGIN = ""
+# The begin marker carries attributes since CB-WP-0046, so it is matched
+# by PREFIX. A bare `` is still a valid marker —
+# every log written before this one has it.
+BEGIN = ""
+# Which rules the session was played under (CB-WP-0046).
+#
+# **A session property, not a note property**, so it rides the marker
+# rather than becoming an eighth column. The variant is fixed at startup
+# and cannot change between games, so a per-row column would repeat one
+# fact on every line and force a third accepted row width on a parser
+# whose docstring is about exactly that hazard.
+VARIANT_ATTR = re.compile(r"variant=([A-Za-z0-9._-]+)")
+
# GameDesign §3.1's figure, shared rather than reinvented: a note that
# cannot expire ages into an apparent finding.
NOTE_EXPIRY_DAYS = 30
@@ -45,7 +58,12 @@ def parse(text):
is precisely the failure this tool exists to prevent."""
if BEGIN not in text or END not in text:
raise ValueError("no trial-log block")
- block = text.split(BEGIN)[1].split(END)[0]
+ # Split off the marker's own line so an attribute cannot be read as
+ # a table row, and so `variant=` is available to every row below it.
+ tail = text.split(BEGIN, 1)[1].split(END)[0]
+ marker, _, block = tail.partition("\n")
+ hit = VARIANT_ATTR.search(marker)
+ variant = hit.group(1) if hit else None
rows = []
for line in block.splitlines():
line = line.strip()
@@ -63,16 +81,21 @@ def parse(text):
# considered, and did not do) would have emptied every existing
# log without a word.
if len(cells) == len(COLUMNS):
- rows.append(dict(zip(COLUMNS, cells)))
+ row = 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 "
f"{len(COLUMNS)} or {len(LEGACY_COLUMNS)}: {line!r}"
)
+ # **`None`, not a default of "baseline"** — a log written before
+ # the marker carried the attribute does not say which rules were
+ # played, and answering an unrecorded question with the common
+ # case is how a baseline note gets read as an H2 result.
+ row["variant"] = variant
+ rows.append(row)
return rows
@@ -174,12 +197,20 @@ def report(root=TRIALS, today=None):
found = logs(root)
print("trials — what the players said, and where\n")
total, orphans, pending, expired, legacy = 0, 0, 0, 0, 0
+ by_variant = {}
for path, rows, _ in found:
name = os.path.basename(path)
age = age_days(path, today)
- print(f" {name} ({len(rows)} note(s), {age}d)")
+ # CB-WP-0046: which rules were played. `unrecorded` is said out
+ # loud rather than filled in with the common case — a note read
+ # under the wrong rules is worse than a note whose rules are
+ # openly unknown.
+ rules = (rows[0].get("variant") if rows else None) or "unrecorded"
+ print(f" {name} ({len(rows)} note(s), {age}d, rules {rules})")
for r in rows:
total += 1
+ key = r.get("variant") or "unrecorded"
+ by_variant[key] = by_variant.get(key, 0) + 1
# 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.
@@ -207,6 +238,15 @@ def report(root=TRIALS, today=None):
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")
+ # A tally by rules, because the question a reader now has is "was this
+ # said about H2 or about the baseline" and counting the logs by hand
+ # is how that gets answered wrong.
+ if by_variant:
+ print(" notes by rules "
+ + ", ".join(f"{v} {n}" for v, n in sorted(by_variant.items())))
+ if "unrecorded" in by_variant:
+ print(" (unrecorded = written before"
+ " CB-WP-0046 stamped the marker)")
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"
@@ -231,13 +271,19 @@ def self_test():
ok = ok and bool(cond)
print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
- def log_of(*rows):
+ # `BEGIN` is a PREFIX since CB-WP-0046, so a fixture has to close the
+ # marker itself — which is the point: the closing text is what the
+ # attribute sits inside.
+ OPEN = BEGIN + " -->"
+
+ def log_of(*rows, marker=None):
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"
+ return (f"# Trial log\n\n{marker or OPEN}\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"
+ legacy = (f"# Trial log\n\n{OPEN}\n\n"
"| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"
"| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n"
f"\n{END}\n")
@@ -256,6 +302,65 @@ def self_test():
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-0046 — WHICH RULES THE SESSION PLAYED.
+ row = "| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n"
+ stamped = log_of(row, marker=BEGIN + " variant=h2-scoped-problem-stress -->")
+ check("a stamped log reports its variant",
+ parse(stamped)[0]["variant"] == "h2-scoped-problem-stress")
+ check("an unstamped log says NOTHING, not 'baseline'",
+ parse(log_of(row))[0]["variant"] is None,
+ "answering an unrecorded question with the common case is how a "
+ "baseline note gets read as an H2 result")
+ # The attribute must not be mistaken for a table row, and adding it
+ # must not change how many notes the log has.
+ check("the marker is not counted as a note", len(parse(stamped)) == 1)
+ check("stamping does not disturb the row", parse(stamped)[0]["after"] == "4")
+ # The marker is not a THIRD row width: the table shape is untouched,
+ # which is why the parser needed no new accepted width.
+ check("the stamped table has the same columns as the unstamped one",
+ set(parse(stamped)[0]) - {"variant"} == set(parse(good)[0]) - {"variant"})
+ # **A LITERAL marker, not one built from `BEGIN`.**
+ #
+ # Every fixture above composes its marker out of `BEGIN`, so all of
+ # them follow `BEGIN` wherever it goes — including away from what
+ # `hotseat.rs` actually writes. Reverting `BEGIN` to an exact match
+ # broke real stamped logs and every one of those checks stayed green.
+ # The format is shared across two languages and this is the only
+ # check that holds this side to the bytes the other side emits.
+ literal = ("# Trial log\n\n"
+ "\n\n"
+ "| n | game | after | round | step | state_hash | comment |\n"
+ "|---|---|---|---|---|---|---|\n"
+ "| 1 | 1 | 4 | 3 | Select | abc123def456 | it felt harder |\n"
+ "\n\n")
+ # Caught, because the failure mode is `parse` REFUSING the writer's
+ # output — and an uncaught traceback names a line number where a
+ # named check names the defect.
+ try:
+ literal_rows = parse(literal)
+ except ValueError:
+ literal_rows = []
+ check("the bytes hotseat.rs writes are the bytes this parses",
+ len(literal_rows) == 1
+ and literal_rows[0]["variant"] == "h2-scoped-problem-stress",
+ "hotseat.rs's the_trial_log_records_which_rules_were_played "
+ "asserts this same string from the writing side")
+ # And the marker as it was written before CB-WP-0046, also literal.
+ try:
+ older = parse(literal.replace(
+ "",
+ ""))
+ except ValueError:
+ older = []
+ check("a pre-CB-WP-0046 marker still opens a log", len(older) == 1,
+ "ADR-0019 D4's discipline: old logs stay readable")
+
+ check("a legacy log can still be stamped",
+ parse(f"# Trial log\n\n{BEGIN} variant=ground-darvo-r0 -->\n\n"
+ "| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"
+ "| 1 | 3 | Select | abc123def456 | x |\n"
+ f"\n{END}\n")[0]["variant"] == "ground-darvo-r0")
+
# 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.
diff --git a/trials/2026-08-08-1903-2.yaml b/trials/2026-08-08-1903-2.yaml
new file mode 100644
index 0000000..2b9f2f9
--- /dev/null
+++ b/trials/2026-08-08-1903-2.yaml
@@ -0,0 +1,156 @@
+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: SOLVE
+ problem: 1
+- 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: 2
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 2
+- 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: GROUND
+- actor: P2
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- 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: SOLVE
+ problem: 2
+- 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: 3
+- actor: P2
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+expect:
+ events: []
+ state: {}
+ rejects: []
+ state_hash: 5c25b5357d90376d23caa08921a448e658c71c2936ab21e84374ad76525788b6
diff --git a/trials/2026-08-08-1903-3.yaml b/trials/2026-08-08-1903-3.yaml
new file mode 100644
index 0000000..5bf287f
--- /dev/null
+++ b/trials/2026-08-08-1903-3.yaml
@@ -0,0 +1,179 @@
+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: 3
+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: INVESTIGATE
+ problem: 2
+- 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: GROUND
+- actor: P2
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P2
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P2
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: choose_ground_mode
+ args:
+ choice: reject_reverse
+ mode: ND
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+- actor: P1
+ cmd: select_action
+ args:
+ action: ATTACK
+ target: P3
+- actor: P2
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: choose_darvo_target
+ args:
+ problem: 1
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+expect:
+ events: []
+ state: {}
+ rejects: []
+ state_hash: ae5b6a484c408dfb951ce82d7951a7f9b9c355e9b1cec394023b198154547744
diff --git a/trials/2026-08-08-1903.yaml b/trials/2026-08-08-1903.yaml
new file mode 100644
index 0000000..6662c6c
--- /dev/null
+++ b/trials/2026-08-08-1903.yaml
@@ -0,0 +1,157 @@
+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: SUPPORT
+ target: P2
+- 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: 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: GROUND
+- actor: P2
+ cmd: select_action
+ args:
+ action: INVESTIGATE
+ problem: 4
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P1
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- 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: GROUND
+- actor: P3
+ cmd: select_action
+ args:
+ action: SOLVE
+ problem: 2
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P2
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- 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: SUPPORT
+ target: P1
+- actor: P3
+ cmd: select_action
+ args:
+ action: GROUND
+- actor: SYSTEM
+ cmd: reveal
+ args: {}
+- actor: P3
+ cmd: choose_ground_mode
+ args:
+ mode: GR
+- actor: SYSTEM
+ cmd: resolve
+ args: {}
+- actor: SYSTEM
+ cmd: end_round
+ args: {}
+expect:
+ events: []
+ state: {}
+ rejects: []
+ state_hash: 7ae42e026cd31344e4ef81b15d3cd7b6639bf9101689d99e8b50ef3614932ed8
diff --git a/workplans/CB-WP-0046-the-rule-the-placement-encodes.md b/workplans/CB-WP-0046-the-rule-the-placement-encodes.md
new file mode 100644
index 0000000..837bad8
--- /dev/null
+++ b/workplans/CB-WP-0046-the-rule-the-placement-encodes.md
@@ -0,0 +1,128 @@
+---
+id: CB-WP-0046
+kind: product
+title: "The rule the placement encodes"
+status: done
+---
+
+# Purpose
+
+```
+structural tier S (one new edition reader, one rendered passage, one
+ marker attribute -- no rule, no dependency moved)
+declared tier S
+```
+
+Closes the two gaps [CB-WP-0045](CB-WP-0045-the-table-names-what-it-shows.md)
+named as *not done*.
+
+## The gap, and the wrong reason given for it
+
+CB-WP-0045 recorded:
+
+> *"Nothing explains what a scope does … it is a `ground-game` question
+> first: the scope rule is ours (Variant), not the edition's, so there is
+> no vendored sentence to render."*
+
+**The premise was false.** `editions/experiments/h2-scoped-problem-stress/`
+ships `Rules_Text.csv`, and its `Problems / Stress scope` passage is the
+rule verbatim:
+
+> *"Each Problem has stress_scope. global: every seat. personal: the
+> assigned owner only. bond: the owner and every seat connected to the
+> owner through one or more Bonds (the Bond network). If the owner has no
+> Bonds, bond scope behaves as personal. Rivalries do not expand bond
+> scope. Multiple unclaimed Problems stack (+1 each) for seats in their
+> scopes."*
+
+**Nothing in clay-borg had ever read the file.** Twenty-two passages of
+player-facing rules, in the repo, unreachable by a player — a whole
+rulebook, where CB-WP-0045 found single columns.
+
+**Third instance of F18's shape in two passes**, and the largest. The
+pattern is now specific enough to name: *when a package adds a file rather
+than a column, nothing notices.* `edition-check` verifies the digest and
+freshness of files it knows; a file no reader mentions is not stale, it is
+invisible. Recorded as a finding for `ground-game` rather than fixed here.
+
+**The escalation is the part worth keeping.** CB-WP-0045 did not just
+leave a gap; it *explained* the gap with an argument that would have
+stopped the next reader from looking. A wrong reason for an open item is
+worse than an open item, because it retires the question.
+
+## The log said nothing about which rules were played
+
+`2026-08-08-1734`: *"Well, i did not notice any difference, maybe i did
+not start the H2 variation?"* **The log cannot answer that** — and neither
+could we, a week later, from the file.
+
+The variant rides the **begin marker**, not an eighth column:
+
+```
+
+```
+
+It is a property of the **session** (fixed at startup, constant across
+`play again`), so a per-row column would repeat one fact on every line and
+force a third accepted row width on a parser whose docstring is about
+exactly that hazard (CB-WP-0032). The table shape is untouched.
+
+**Stamped from `state.variant`, never from the `--variant` flag.** Three
+call sites once wrote `state.variant = v` directly instead of
+`with_variant()`, which leaves H2 inert; a log stamped from the flag would
+have recorded `h2` for a session that played the baseline. **A false
+provenance on real player words is worse than no provenance** — which is
+also why an unstamped log reports `unrecorded` and not `ground-darvo-r0`.
+
+## Task: render the rule, stamp the log
+
+```task
+id: CB-WP-0046-T01
+status: done
+priority: high
+```
+
+**Controls, all mutation-proven:**
+
+| mutation | what went red |
+|---|---|
+| the scope rule never renders | *"Problems are placed by scope and nothing says what a scope does"* |
+| it renders always, baseline included | *"the baseline table explains a scope distinction that is not in play"* |
+| our paraphrase instead of the passage | *"the scope explanation is not the edition's own words"* |
+| the writer drops the attribute | *"the log does not say which rules were played"* |
+| unstamped parses as `ground-darvo-r0` | *"an unstamped log says NOTHING, not 'baseline'"* |
+| `BEGIN` back to an exact match | *"the bytes hotseat.rs writes are the bytes this parses"* |
+
+**The last mutation is the finding.** It went green the first time. Every
+`trials.py` fixture composed its marker out of `BEGIN`, so all of them
+**followed `BEGIN` wherever it went** — including away from what
+`hotseat.rs` actually writes. Nine checks about the marker, and reverting
+the marker broke every real stamped log with all nine still green.
+
+The format is shared across two languages, so the control has to be a
+**literal**: the exact bytes the writer emits, asserted from the reading
+side, with the writer's own test asserting the same string from the other.
+A fixture built from the constant under test cannot test the constant.
+
+`stress_scope_rule()` matches on the **heading**, not on row 20 — a row
+number is a property of today's file, a heading is a property of the rule
+— and returns `None` if the edition stops carrying it (ADR-0018: an absent
+explanation is reported absent, never replaced by ours).
+
+**Done 2026-08-08.** `make all` green; `cargo test`: 80 render + 35 play;
+24 `trials.py` checks. Verified on a live `make ground VARIANT=h2` page.
+
+## Not done here
+
+- **The other 21 passages are still unread.** `h2_rules_text()` returns
+ the whole rulebook and one caller uses one row of it. The Round-step
+ passages in particular are the account of the sequence the player is
+ standing in, and there is nowhere on the page that says it.
+- **The baseline has no `Rules_Text.csv` at all**, so this explains only
+ the experiment. A baseline player still gets no rulebook — the reverse
+ of the usual gap, and a `ground-game` question.
+- **The 11 existing notes stay `unrecorded` forever.** Their variant was
+ never written down; inventing one is the defect this pass exists to
+ avoid. `2026-08-08-1818`'s three notes are almost certainly H2 on the
+ internal evidence of what they say, and *almost certainly* is not a
+ provenance.