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>
197 lines
8.9 KiB
Markdown
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 |
|