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

197 lines
8.9 KiB
Markdown

# 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](../workplans/CB-WP-0027-the-commentary-track.md),
[ADR-0007](ADR-0007-render-html-not-a-port.md) D5,
[ADR-0012](ADR-0012-the-design-instrument.md) D6 (notes),
[GameDesign.md](../specs/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
`--record`ed 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**:
```rust
_ => 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 `GroundCommand`** —
> `resolve()` 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 |