clay-borg/decisions/ADR-0014-the-commentary-track.md
tegwick 4fb506fcd1 CB-WP-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot
carry a move.

T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was
easy is that PointerFact::parse already refuses any unrecognised field --
a comment could not reach the command path even by accident. So /command
carries pointer facts, /note carries text, and Note has no code path to
GroundCommand. Comments live in trials/<date>-<slug>.md, not in
ScenarioFile: a scenario is executed, replayed and hashed, and prose in it
is data the runner must ignore, which is how a format rots. The state hash
binds; round and step are for reading. And the retention question, decided
before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note
reaches ground-game only by being promoted to a register finding, by a
human, with the wording chosen then -- "the DARVO sequence is infuriating"
is useful signal and a bad way to open a message to the game's designer.

T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a
grid child defaults to min-content width and without it the SVG table
refuses to shrink and pushes the meta column off-screen, looking correct
on the developer's monitor and broken everywhere else. Single-column
fallback under 64rem. The running tally moved into the panel so it is
visible WHILE PLAYING; it only appeared on the ending page before, and a
score you see once the game is over informs nothing.

T03. A plain <form method="post">, so the box works with the script
disabled; the command channel needs JavaScript because a drag is not a
form submission, a comment is one. 303 See Other so a reload does not
re-post. esc()'s first hostile input: <script>alert(1)</script> renders
escaped AND STILL READABLE -- escaping that eats the player's words is its
own defect. Verified over real HTTP: note posted 303, hostile note stored
as text, empty note refused 400, game did not advance.

T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE
I RAN IT: the first version called any note without a recording an orphan,
so a live session reported every note as broken -- the recording is only
written at game end. A metric that cries wolf is one nobody reads, which
is the exact failure this pass exists to prevent. Now ok / pending /
orphan, and only orphan is a target-0 number. The self-test exercises the
REPORTING path, not just the parser, because design-baseline.py had a
green self-test and an unexercised reporting path and that is where it
rotted.

And a latent Makefile defect surfaced: make trials did nothing, because
trials is also a directory and Make saw an up-to-date file. design,
difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass --
were ALL missing from .PHONY; only the one that collided revealed it.

make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:37:46 +02:00

8.9 KiB

ADR-0014: a second channel that cannot carry a move, and a trial log that a command can read

status: accepted date: 2026-08-06 decided by: agent, under the standing loop authorization tier: M (structural M — touches ADR-0007 D5's input contract and the recorded-session format; chaos d8=7 → no override). Tier M merges survey and decision into one document, which this is. references: CB-WP-0027, ADR-0007 D5, ADR-0012 D6 (notes), GameDesign.md §3.1 and §5

Context

A test player wants to say why they played a move, and what confused them, while the position is still on screen — and have it reviewable later.

GameDesign §5 already specifies the artifact: a trial is a --recorded session plus a sibling observation log. What it does not specify is how the log gets written, and as written it asks the player to reconstruct their reasoning afterwards from memory. That is why no trial log exists yet.

The survey, such as it is

The candidates are all in this repo, so there is nothing external to benchmark against — which is why tier M's merged form is enough.

existing mechanism what it does why it is not this
provisional: true + owner + date marks an undecided rule about the dataset, not about play
the finding register records findings with reproductions needs the reproduction this is meant to supply
<!-- design-register:begin --> + a table a machine-readable block inside a human document the right shape — reused below
the game log what happened not why, and not how it felt
.cbreplay bundles the position, replayable the anchor, not the annotation

The one thing worth stealing is the register's own idiom: a table between HTML-comment markers inside a readable Markdown file, parsed by a small tool. It survived contact in FindingRegister.md, design.py already parses that shape, and it means a trial log is readable by a human without a tool and by a tool without a parser library.


D1 — a comment is not a command, and the types must make that true

ADR-0007 D5 is scoped, not amended.

D5 says JavaScript may not construct commands — the page reports raw pointer facts, and Rust decides what they mean. That rule is about the command channel and it stays exactly as it is.

The relevant fact is that PointerFact::parse refuses any unrecognised field:

_ => return Err(format!("unrecognised field in pointer fact: {pair:?}")),

So a comment cannot be smuggled through the existing path even by accident. Rather than widening that parser, the comment gets its own one, and the separation is structural:

POST /command carries pointer facts. POST /note carries text. Note is a distinct type with no code path to GroundCommandresolve() takes a PointerFact and nothing else, so a note cannot become a move by any route, including a future careless one.

Control: a test posts a note whose body is a well-formed pointer fact (down=action-solve&up=problem-1) and asserts no command results and the game does not advance. Without it, "the channels are separate" is a claim about code layout rather than about behaviour.

What would falsify D1: if Note ever acquires a field the engine reads, the separation is gone and ADR-0007 says to revisit the decision rather than widen the control. The note type carries text, a position, and a timestamp. Nothing else.

D2 — comments live in the trial log, not in the scenario

Not in ScenarioFile. It is deny_unknown_fields and CB-WP-0026 just demonstrated why that is right — but the reason to keep prose out is not strictness, it is that a scenario is a game record and must stay one. Scenarios are executed by make sim, replayed by replay-test, and compared by hash. A player's opinion in that file would be data the runner must ignore, which is how a format rots.

In trials/<date>-<slug>.md, exactly as GameDesign §5 already says, with the recording beside it as trials/<date>-<slug>.yaml.

The log is a readable Markdown file with one machine-readable block:

<!-- trial-log:begin -->
| n | round | step | state_hash | comment |
<!-- trial-log:end -->

Reusing FindingRegister.md's idiom deliberately — same marker shape, same table form, so design.py's parser is the model and a reader who has seen one has seen both.

D3 — the binding is the state hash, with round and step for humans

A comment records state_hash (cb_events::state_hash_hex), plus round and step.

  • The hash is the binding. It is what already makes a session comparable to its replay (Summary::end_state_hash), so a comment keyed to it names a position a reader can reach, not merely describe.
  • Round and step are for reading. "Round 3, Resolve" orients a human instantly; a hash does not.

A comment whose hash no longer appears in its recording is reported as orphaned, not deleted. The position may have moved because the engine changed, and that is worth knowing — it is the same reasoning that made gr-e01 a rewrite rather than a deletion (CB-WP-0021 T03), and the same signal as a reproduction that has gone green (GameDesign §1.3).

D4 — comments stay here; only findings travel

The retention question, decided before any comment is written.

These are the maintainer's own words about his own game, written in the moment, and some will be unflattering about the design, the engine, or both. That is the point — a commentary track that people self-censor into is worthless.

Raw comments never leave clay-borg. Nothing auto-forwards to ground-game.

A comment reaches ground-game only by being promoted to a register finding and reported through the existing path — which requires a human to promote it, and requires the finding to meet GameDesign §1.

So the trial log is a private notebook, and the register is the published surface. The promotion step is where wording gets chosen deliberately, which is the right place for it: "the DARVO sequence is infuriating" is useful signal and a bad way to open a message to the game's designer.

Who decides: the maintainer, per comment, at promotion time. Not a rule, not a default, and not the agent.

D5 — a comment is a note, and inherits the note tier

A promoted comment enters the register as a note (ADR-0012 D6): kind by judgment, state note, 30-day expiry on the existing machinery.

It is not automatically a finding, even though it has a position. GameDesign §1 wants a reproduction that shows the claimed thing — a recording proves the position existed, not that anything is wrong with it. "This felt pointless" plus a replayable state is a strong note and still a note.

What promotes it further is the same as for any other note: an artifact demonstrating the defect. The trial log makes that cheap, because the position is already recorded and someone can go and build a scenario from it.

D6 — surfacing is the deliverable

make trials reports every comment with its position and age, across all trial logs, and flags orphans.

Storage without surfacing would make this the third instance of this project's signature failure — after the four-day unread message and the ten uncollected rulings. The workplan says no task may be called done while comments are write-only, and this ADR agrees: T04 is the pass, T03 is plumbing.

Control: the reporting path is exercised by --self-test, not only the parser. design-baseline.py had a green self-test and an unexercised reporting path, and the reporting path is where it rotted (ADR-0012 D8).

Consequences

  • cb-render-html gains a Note type and a /note route; PointerFact is untouched.
  • trials/ is created, with .md + .yaml per trial.
  • make trials reports them; --self-test covers the reporting path.
  • specs/GameDesign.md §5 gains the log's concrete format — it specified the artifact and not its shape.
  • Comments are escaped on render (esc), and this is the first user-authored text this renderer has handled.

What was rejected

rejected why
comments inside ScenarioFile a scenario is a game record; prose there is data the runner must ignore
widening PointerFact to carry text D5's parser refusing unknown fields is a control, not an inconvenience
free-form prose with no machine-readable block unreadable by a command, which is the failure mode this exists to avoid
auto-forwarding comments to ground-game invites self-censorship, and the promotion step is where wording belongs
treating a positioned comment as a finding a recording proves the position existed, not that anything is wrong
deleting orphaned comments a moved position is a signal, per CB-WP-0021 T03