Merge commit 'ad217cc12a48ed7e3900aa02f94aae1a6fe4c857' into docs/sboz-stable-identities

This commit is contained in:
Edward Langley
2026-09-24 15:38:24 -07:00
9 changed files with 442 additions and 171 deletions
+20 -6
View File
@@ -75,14 +75,28 @@ defect is a test that passes.
- Wait on checks with `scripts/pr-watch <pr> --expect <sha>`. Never hand-roll a
poll; see the `pr-watch` skill for the four ways they report green while wrong.
- Review before merging. Copilot's quota may be exhausted and its request is a
**silent no-op**; an absent review and an unrequested one both read as
"pending". Dispatch an adversarial reviewer and give it the head SHA, the base,
and a test-merge, so it reviews the tree that will exist. Point it at
`agent-rules.md`, "GitHub", for what a reviewer reads and answers first.
- Review before merging. Dispatch an adversarial reviewer and give it the head
SHA, the base, and a test-merge, so it reviews the tree that will exist. Point
it at `agent-rules.md`, "GitHub", for what a reviewer reads and answers first.
Copilot reviews here again (2026-09-24), mostly in inline comments, and Codex
as quota allows. Read both with `scripts/pr-reviews <n>` and see each finding
answered before merging. Do not wait on either: an absent review and an
unrequested one both read as "pending".
- Only the user's explicit approval accepts an ADR, and lets its PR merge. The
review gates still apply, and none of them is approval:
`AGENTS.md`, "ADR pull requests".
`AGENTS.md`, "ADR pull requests". **While an ADR PR is open, keep a
background poll** for new non-agent comments and reviews, and resume its
steward on each one. The user answers inline, and an answer left unread for
hours is a failure.
- **Before merging, check `baseRefName` is `main`**
(`gh pr view <n> --json baseRefName`). A stacked PR whose base has already
merged still targets the stale base branch, and merging it lands nothing in
`main`. Retarget it with `gh pr edit <n> --base main` first. `AGENTS.md`,
"Shipping a wave", has the 2026-09-24 incident.
- **Land a batch of approved PRs as one integration PR.** Evidence files are
append-at-EOF, so merging one at a time conflicts every other open PR. Then
diff every conflict resolution against both parents and the merge base, and
run the gates on the merged tree: `AGENTS.md`, "Merging many PRs at once".
- A re-review is of the **delta**, at a named SHA. A review of a superseded
commit reads as a pass.
- Merge on GitHub, then `git fetch gh && git merge --ff-only gh/main`. Close
+34 -5
View File
@@ -95,6 +95,16 @@ out on purpose:
- **Never pipe a gate.** `$?` must be the gate's own. And
`check-comment-runs.py --check-evidence` prints two lines with the comment
cap **first**, so `| tail -1` silently drops half of it.
`fmt-workspaces.sh` is the one most often lost this way: an unformatted tree
exits **1** and prints a diff, and `| tail` shows the diff's last lines and
exits 0. Read its exit status, or run it with `--fix` and commit what it
rewrites.
- **CI takes every tool from `nix develop .#ci`, and so should your scripts**:
no `/bin/…` or `/usr/bin/…` paths, and no bash-version or tool-version
probes (`AGENTS.md`, "CI takes its tools from the dev shell"). A test that
needs a restricted `PATH` builds one on purpose. It does not assume the
runner's `/bin` holds anything, because on the NixOS runners it holds only
`env` and `sh`.
- **`--no-fail-fast`**, because plain `cargo test --workspace` stops at the
first failing binary. Use it for any claim about blast radius.
- If you touch a browser-bundle crate (core, formula, io, protocol, command,
@@ -109,6 +119,13 @@ out on purpose:
never runs them. They failed only in CI once. A fresh worktree has no
`harness/node_modules`, and Playwright then reports a missing
`@playwright/test`, which reads like a broken checkout.
- **Playwright serves `harness/serve/`, not `web/`.** `harness/build.sh`
assembles that tree and it is gitignored, so after any change to `web/*.js`
— yours, or one a merge brought in — re-run `./harness/build.sh` before the
specs. Otherwise they test the old page. That failure looks exactly like a
real regression (`doctrine(rebuild_the_assembled_site_after_merging_web)`).
CI runs Chromium and Firefox only (`--project=chromium --project=firefox`),
so run those two locally.
- **If you change the grid's row or column count, also grep
`harness/fixtures/*.improv` and `harness/tests/*.spec.js`.** On PR #176 a
Playwright spec pinned the old collapsed-group row count. `cargo test` cannot
@@ -181,6 +198,12 @@ out on purpose:
(module-qualify the name when the leaf is not unique). It is append-at-EOF, so
**expect a conflict**. Keep both sides and verify line by line against both
parents: `--check-evidence` cannot see a dropped entry.
- **Never resolve a conflict with `git checkout --ours` or `--theirs`.** Either
one takes the whole file from one side and silently drops the other side's
*non*-conflicting changes to it. That is how a `ci.yml` step vanished while
the merge message said it was unchanged (improvise-dndu). After resolving,
diff the merge against each parent and against the merge base
(`AGENTS.md`, "Merging many PRs at once").
- **Comment runs are at most two lines.** Reasoning goes in `context/`, not in
comments (`context/design-principles.md` §6).
- **If the acceptance criteria look wrong, argue it** rather than following
@@ -204,9 +227,11 @@ text for any of these in your hand-back.
apart from an automated Codex review (`AGENTS.md`, "Shipping a wave").
- The attribution line, commit trailers and PR footer all come from your brief.
They name the agent and the session, so this shared file cannot hold them.
- Read reviews with `./scripts/pr-reviews <n>`, and do not pipe it. How to tell
a ChatGPT Codex review from ours, and why Copilot is not coming: `AGENTS.md`,
"Shipping a wave".
- Read reviews with `./scripts/pr-reviews <n>`, and do not pipe it. It also
shows inline comments, which is where Copilot puts most of its findings.
Copilot reviews here again (2026-09-24), so answer its findings as you answer
Codex's. How to tell a Codex review from ours: `AGENTS.md`, "Shipping a
wave".
- **As a PR's author, reply on the PR to every review finding you address.**
Name the commit that fixes it, or say why you declined it. Putting it only in
your hand-back to the orchestrator is not enough: the PR thread is where the
@@ -219,8 +244,12 @@ text for any of these in your hand-back.
and the thread is still where the next reader looks.
- **Do not merge, and do not close beads.** Your part ends at an open PR plus
your hand-back. What "done" means is in `AGENTS.md`, "Repository policy over
the Beads blocks". Avoid stacked PRs. If you must stack, never let the base
branch be deleted.
the Beads blocks". Avoid stacked PRs. If you must stack, say in the PR body which PR it is
stacked on. Once that base PR merges, check the stacked PR's `baseRefName`
(`gh pr view <n> --json baseRefName`) and, unless it is `main`, retarget it
with `gh pr edit <n> --base main` before anyone merges it: merging it
otherwise lands it in the stale base branch, not in `main`. Fifteen PRs did
that on 2026-09-24 (`AGENTS.md`, "Shipping a wave").
## Hand-back, per ticket
+117 -12
View File
@@ -172,9 +172,12 @@ on merged PRs and were not read until afterwards, one of them a live P2
(improvise-x1ns). Both were correct.
```bash
gh pr view <n> --json reviews -q '.reviews[]|"[\(.state)] \(.submittedAt) \(.commit.oid[0:8])\n\(.body)"'
./scripts/pr-reviews <n> # bodies, inline comments and issue comments, in time order; do not pipe it
```
`gh pr view <n> --json reviews` is not a substitute: it shows review bodies
only, and most of Copilot's findings are inline comments.
Two things make a Codex review easy to miss. It arrives as a **review**, not a
comment, so a glance at the comment thread does not show it. And it posts under
the repository owner's account — the same account our own agents post under —
@@ -188,15 +191,26 @@ Reviews arrive **as the owner's quota allows**, so absence is not evidence.
Wait where a review is plausible. How a review is answered once found, by the
author and by a reviewer, is `.agents/skills/wave/agent-rules.md`, "GitHub".
**Copilot is not reviewing this repository, and the request is a silent no-op.**
Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 Copilot reviews; #61,
#66, #68 and #70 carry none. Both request paths report success and produce zero
`review_requested` events — the GraphQL mutation returns `clientMutationId: null`
and the REST call returns 200 with `requested_reviewers: []`. So **do not wait on
it**: an absent review and an unrequested one look identical from outside, which
is `doctrine(review_is_requested_not_inherited)` seen from the requester's side.
Review is an adversarial agent the orchestrator dispatches, given the head SHA.
The mechanics below are kept for the day it comes back.
**Copilot is reviewing this repository again, so read its findings and answer
them exactly as you answer Codex's.** On 2026-09-24 it posted inline findings on
#221, #251, #274, #295 and #298, after a lapse that began between #27 and #61
(measured 2026-09-20: #12, #21 and #27 carry 2, 8 and 4 Copilot reviews, and
#61, #66, #68 and #70 carry none, while both request paths reported success and
registered nothing). Two things carry over from the lapse:
- **Its findings arrive mostly as inline review comments**, not as a review
body. `scripts/pr-reviews <n>` reads bodies, inline comments and issue
comments together; `gh pr view --json reviews` shows only the bodies, so it
misses them. Answer each on its own thread, as agent-rules, "GitHub" says.
- **Do not wait on it.** It reviews as its quota allows, and an absent review
and an unrequested one still look identical from outside, which is
`doctrine(review_is_requested_not_inherited)` seen from the requester's side.
The review that gates a merge is still an adversarial agent the orchestrator
dispatches, given the head SHA. Copilot's findings are read *in addition*,
before the merge, like Codex's.
The request mechanics below are what worked before the lapse. Whether they work
now is not re-measured.
Copilot review must be **re-requested after every fix push**; it does not
re-review on its own. The mutation takes a **`botIds`** field — `userIds` fails
@@ -252,12 +266,96 @@ is to branch from `gh/main`, cherry-pick, open a follow-up PR, and delete the
stray re-creation. Check `gh pr view <n> --json state` *before* pushing a fix to
a PR you have not looked at in a few minutes, rather than after.
**A stacked PR whose base PR has already merged merges into that stale base
branch, not into `main`.** GitHub merges a PR into whatever `baseRefName` says,
and it did not retarget these. On 2026-09-24 fifteen approved stacked PRs
(#255–#286) were merged into a base whose own PR had **already** merged, and
the merges read as done. Recovery took seven landing PRs (#289–#295), each
opened from a stack's tip against `main`, and their titles name exactly those
fifteen. Four more in that range (#259, #263, #272, #273) also merged into a
non-`main` base, but one whose PR was still open; they reached `main` when their
base did, which is stacking working as intended. The repository reports `delete_branch_on_merge: true` as measured on
2026-09-24, and the stale bases (`collapse-fu/h6z1`, `fix/enz6-advance-onto-fold`
and others) were still on GitHub afterwards, so that setting is not the guard.
The guard is a check before every merge:
```bash
gh pr view <n> --json baseRefName -q .baseRefName # must print main
gh pr edit <n> --base main # if not, once its base PR has landed
```
Then read the PR's diff again: a retargeted PR's diff now includes its base's
commits if the base did not land.
Merge only once a review of the PR's current head has passed — an adversarial
agent's while Copilot is absent. What a review must state is agent-rules,
"GitHub". Prefer a **merge commit** —
agent's — and every Codex and Copilot finding on the PR has an answer. What a
review must state is agent-rules, "GitHub". Prefer a **merge commit** —
`AGENTS.md` says merges over rebases, and a PR's commits are individually
meaningful.
### Merging many PRs at once
Three things went wrong when a day's approved PRs landed together, and each is
invisible to CI on either side alone.
**Land a batch as one integration PR, not one PR at a time.** Every PR appends
its bug evidence at the end of a `context/test-evidence/*.md` file. So each
merge makes every other open PR conflict at EOF, and merging one at a time
means re-resolving the same files once per PR — and a PR that conflicts gets no
CI run at all (agent-rules, "Gates"). On 2026-09-24 the approved PRs landed as
two integration PRs instead: #298 (nineteen PRs) and #299 (the browser-editor
stack). This is what "one PR per wave" above already intends.
**After resolving a conflict, diff the result against each parent and against
the merge base.** `git checkout --ours <file>` or `--theirs` takes the whole
file from one side, so it silently drops every *non*-conflicting change the
other side made to that file. #245 resolved a `ci.yml` conflict with #228 by
`git checkout --theirs`, took #228's whole file, and lost its own
`check-harness-lock --allow-network` step, while the merge message said the step
was unchanged (improvise-dndu). Nothing refused it: `check-ci-graph.py` counts
suites, not that step. Resolve by hand, then check the merge commit:
```bash
git diff <merge-base> HEAD -- <file> # both sides' changes together, and nothing else
git diff HEAD^1 HEAD -- <file> # what the second side brought
git diff HEAD^2 HEAD -- <file> # what the first side brought
```
**Two changes to one test file can merge cleanly and still fail together.** #287
(the NixOS runners) found that `/usr/bin:/bin` there holds only `env` and `sh`,
and gave section 5 of `checkers-test.sh` a hermetic `utilbin` PATH. #237 added
section 5b on a branch cut before that, still using `PATH=…:/usr/bin:/bin`. Each
passed CI alone, the merge was textually clean, and 5b failed on every PR after
it (#296). So a fix to an idiom — a hermetic PATH, a scrubbed environment, a
helper every case must call — has to be applied to **every case in the file**,
including cases that exist only on another open branch. When you merge, grep
the merged file for the old idiom. Then run the gates on the merged tree, not
on each side.
### CI takes its tools from the dev shell
These are the user's rulings of 2026-09-24:
- **CI runs on self-hosted NixOS microVM runners** (`runs-on: [self-hosted,
nixos, microvm]`). Every `run:` step runs in `nix develop .#ci`, through the
workflow's `defaults.run.shell`. A `uses:` step (an action such as
`actions/checkout`) is not a shell step and does not go through it. A missing tool goes into the `.#ci` shell in
`flake.nix`, not into a per-step `nix shell` wrapper.
- **No hard-coded `/bin` or `/usr/bin` paths.** On these runners those
directories hold only `env` and `sh`. Take tools from `PATH`.
- **No version floors and no probes.** Scripts and tests must not care which
bash or standard utilities are present. `checkers.sh` no longer pins a bash
(it did, at 5.1, until #297). A script that needs a newer feature fails where
it uses it. The argument is `context/code/tooling.md` §5.
- **Playwright's browsers come from nix.** The dev shell sets
`PLAYWRIGHT_BROWSERS_PATH` to nixpkgs' browsers, so there is no install step
and no version check. `npm ci` stays, because `@playwright/test` is not
nix-provided.
A test that fakes an environment must build it on purpose, for example a
hermetic `utilbin` of linked tools. It must not assume the runner's `/bin`
holds anything.
### ADR pull requests
**Only the user's explicit approval accepts an ADR (`context/adr/`), and only
@@ -277,6 +375,13 @@ modelled on PR #122 (ADR 004):
applies after the approval. On #122 the user's own review landed as the PR
merged.
**An open ADR PR is watched, not visited.** The user answers the numbered
questions inline, and reviews arrive as they arrive. An answer left unread for
hours is a failure (the user, 2026-09-24). So while an ADR PR is open the
orchestrator keeps a background poll for new non-agent comments and reviews
since its last pass, and resumes the steward agent on each new item. The
steward folds each answer in and replies on its thread.
### Never hand-edit `.beads/issues.jsonl`
bd manipulates that file and auto-exports to it. Editing it — or even
+8 -7
View File
@@ -343,13 +343,14 @@ worse than no claim at all. Three consequences:
`finished in` per binary. Read your own run.
- **If you cite a figure, name the machine you took it on.** A number with no
machine is a property of the suite, which it is not.
- **The portable signal is the ratio, not the seconds.** One binary an order of
magnitude above the run's **median** is the thing to look at — not above
second place, which it need not be: on one machine `improvise-io` is 2.2×
the runner-up and ~15× the median. It is the same binary on all three
machines it has been measured on: `improvise-io`'s lib, which
was proptest-dominated by the grammar-walking `.improv` generator and the
round-trip properties over it.
- **The portable signal is a ratio, not the seconds — and not the ratio to
the median.** Most binaries print `0.00s` at two decimals, so the median is
zero and a ratio to it is undefined (improvise-i5z6, 39 binaries). Compare
the slowest binary with the next few slowest, across several runs: an
outlier is one that stays well clear of them *in every run*. Both outliers so
far were found that way and had one cause, a generator re-parsing its pest
grammar per case: `improvise-io`'s lib (improvise-8shu) and
`improvise-formula`'s lib (longest of 39 in all three i5z6 runs).
**improvise-8shu is closed, and its diagnosis was wrong** — which is the more
useful half. The ticket blamed an unbounded grammar walk and asked for a
+160 -128
View File
File diff suppressed because one or more lines are too long
+41
View File
@@ -1731,3 +1731,44 @@ already requires the dev shell for `tpl`, and the dev shell's `stat` is GNU.
From `ci.yml`, the toolchain version probes ("the dev shell supplies the pinned
web toolchain", "Playwright matches the dev shell's browsers") and every
`shell: bash` override, so every step runs in `nix develop .#ci`.
### A thread past ARG_MAX is read from stdin — `scripts/pr-reviews-test.sh` (improvise-5jxt)
**What broke.** `fetch` handed the review, inline-comment and issue-comment
JSON to one `jq -n --argjson …` call as arguments. Once #210's comments
outgrew the kernel's argument limit, `execve` refused to start jq:
`jq: Argument list too long`, exit 126, with nothing printed. That happened
with or without `--wait`. The script is how agents read reviews, so an ADR
steward was blocked on it.
**Fix.** A `combine` helper sends the three documents to `jq -s` on stdin
through `printf`. `printf` is a bash builtin, so no `execve` sees the data.
Both paths use `combine`: the fixture path and the live `gh` path. The other
jq calls in `pr-reviews` already read `$json` from a pipe. `--arg head` carries
only a 40-character SHA.
**The fixture.** Each of the three surfaces carries about 2 MB: a review body,
an inline comment followed by a small one after it, and an issue comment. 2 MB
exceeds both macOS's total `ARG_MAX` (1 MB) and Linux's per-argument
`MAX_ARG_STRLEN` (128 KB). Every surface is large, so a fix that moves only one
document off argv still fails. The file is built with `head -c`, `tr` and
`printf`, so it depends on no tool version and no path.
**Red first.** Before the fix, "a thread larger than ARG_MAX is still read" and
"… still satisfies --wait" exited 126, as #210 did live.
**Rivals** (each 25 passed, 5 failed; the five are the new cases):
- **The pre-fix script**, restored from `gh/main`: exit 126 on both run cases,
and all three print assertions miss.
- **A partial fix** that pipes the review and inline documents but still
passes the issue comments with `--argjson issue "$3"`: also exit 126. So the
large issue fixture is load-bearing, not decoration.
**Live.** `./scripts/pr-reviews 210` exits 0 with its output sent to a file.
The output includes the user's inline answers on
`context/adr/007-stable-identities.md` from 2026-09-24T19:50:22Z onward
("full unicode", "Just a plane number.", …).
**Scope sweep.** `scripts/pr-watch` passes its JSON through `printf '%s' | jq`,
never as an argument. `grep -rn 'argjson\|--arg '` over `scripts/`,
`.agents/` and `.claude/skills/` finds no other data argument.
+24 -5
View File
@@ -138,6 +138,9 @@ invariant(a_reported_share_cannot_overflow_its_scale,'gp_tree::fit::importance_r
invariant(viewport_extent_is_external_on_both_front_ends,'The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem. run_headless_commands reads crossterm::terminal::size() into app.term_{width,height} and CmdContext::{visible_rows,visible_cols} derive from it: 80x24 gives visible_rows 16, 200x60 gives 52. Measured on a 40-row grid, the SAME command sequence -- one PageDown then clear-cell at the cursor -- deleted R12 at 80x24 and R39 at 200x60. So Client::set_viewport is not a browser-introduced input; it is the browser SPELLING of one the TUI has always had, and gotcha 47 \"not derivable\" is true of both front ends. Any ADR treating extent as a browser problem produces a log that replays in a browser and not in a terminal. Cheapest of the five external inputs to close -- one pair of integers with an existing wire spelling, ClientFrame::Viewport -- but it must land in the SAME CHANGE as improvise-6uva, which currently masks it. A test must drive TWO sizes; one cannot see this.').
invariant(aggregate_hash_is_layout_scoped,'A RecordHash falls back to DataStore maintained index and so resolves against the DOCUMENT, which is why a connected client SetCell works from any view. An AggregateHash has no such index AND CANNOT HAVE ONE -- an aggregate has no existence outside the layout that derives it. So App::resolve_aggregate answers only for the HOST active view, which since improvise-jytm is not the client. Guarded by an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view, whose CONTROL is the load-bearing half: the same surrogate resolves once the host is moved onto that view, so a blanket None -- a typo in the hash, an empty layout -- cannot pass.').
invariant(fmt_gate_exit_two_means_the_gate_did_not_run,'scripts/fmt-workspaces.sh: exit 1 NAMES A FILE and blames the tree; exit 2 names none and blames the invocation. Every exit-2 cause is checked ABOVE the formatter loop, so the two cannot be confused. The contract is deliberately \"did the gate run\" rather than \"what kind of failure was it\": telling a rustfmt diff from a parse error means PARSING RUSTFMT PROSE, which is the check-adjacent-to-its-subject shape this repo keeps losing to. Measured while narrowing it: an unparseable source exits 1, identical to a diff, and an unknown rustfmt.toml option is only a warning at 0. Caught by ChatGPT Codex, which found the script committing its own ticket defect via a blanket \`|| rc=1\`.').
invariant(a_frame_group_level_names_a_category,'An AxisFrame deserialises through UncheckedAxisFrame and REFUSES a FrameGroup or GroupEnd whose level is >= cats.len(). A level naming no category is a malformed frame from the wire, never something to clamp, so the check is at the one door every frame arrives through. context/code/protocol.md section 3b.').
invariant(a_grid_evaluates_only_through_its_own_evaluator,'GridLayout::evaluator builds a GridEval whose fold memo that layout built from its own none_cats and hidden items, and a grid evaluates only through it, so no other grid memo can reach its cells or totals (improvise-opbo, where a memo built by another layout silently lent its aggregation and hidden set). prune_empty shares one GridEval across its whole emptiness test rather than a fresh memo per cell (improvise-metf).').
invariant(an_unprojected_cell_is_unknown,'Every client read of the grid cache goes through projected_at, which subtracts the frame origin (AxisFrame::first) before indexing. A position outside the window the cache holds is UNKNOWN, never the value at the same cache index, and a cell op on it waits. Reading the cache through offsets the client had scrolled ahead to made a pipelined edit write a different cell than the TUI would (improvise-7mro).').
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
@@ -314,7 +317,6 @@ doctrine(cfg_test_and_pub_crate_are_different_walls,'A visibility plan written f
doctrine(cargo_tree_shows_dev_edges_by_default,'Asking whether a feature leaked a dependency into a shipped build, a plain cargo tree -p improvise-protocol --target wasm32-unknown-unknown reports serde_json PRESENT -- because the default output includes dev edges. --edges normal is what answers the question, and it reports 0. Verified both ways by the orchestrator. Same class as grep_over_git_diff_is_silently_empty_under_an_external_differ: a command that looks like it answers the question, answering a neighbouring one, in the direction that reads as a finding rather than as a pass. A feature that adds a dependency to a wasm bundle crate must be checked with --edges normal, and per-crate, because the CI wasm job checks each crate separately and feature unification cannot reach across that.').
doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated,'PR #98 predicted the composition-time mode drift itself and declared it unreachable BECAUSE the toggle route (R) is a stranding decline in the browser. True of that route. A VIEW SWITCH reached the same drift, because records-ness is auto-detected from the axes, so a switch changes it with nothing in the batch saying \"records\" -- found by ChatGPT Codex. The ENUMERATION of routes is the part nothing checks, so an unreachability argument is evidence about the routes you thought of and about nothing else. Same shape as a_mitigation_that_names_the_wrong_file_is_not_a_mitigation. The remedy is to close the CLASS by observation rather than to patch the route.').
doctrine(a_precondition_derived_from_its_own_expectation_guards_nothing,'A loop test whose precondition is computed from the same tuple element as the assertion cannot be relaxed one iteration at a time: relaxing the assertion relaxes the guard too, and the test then fails for the wrong reason -- which reads exactly like the bug. Assert the two as SEPARATE facts. Corollary for check_the_checker over a loop: an injection that stops the loop at iteration 1 leaves every later iteration untested, so each direction must be reached deliberately. Found by the PR #98 author in its own bidirectional test while proving the second direction.').
doctrine(a_pipe_discards_the_exit_code_you_meant_to_read,'FOURTH occurrence in one campaign, and the last one was in a tool written TO BE the reliable answer. scripts/pr-watch once read tr exit status through a pipe. An orchestrator probe reported EXIT=0 from a checker that had exited 1, because the pipeline ended in tail. Another read PIPESTATUS in zsh, where the array is lowercase pipestatus. And scripts/pr-reviews --wait correctly exited 1 on a timeout while the invocation \`... | tail -20; echo\` reported 0 -- so a review window that had EXPIRED read as a review that had ARRIVED, which is the dangerous direction. In bash and zsh the exit status of a pipeline is the LAST command, so any \`cmd | head\`, \`| tail\`, \`| grep\` discards it. Redirect to a file and read the file, or check PIPESTATUS[0] in bash / pipestatus[1] in zsh -- and note the two shells differ, which is its own trap. A gate whose status you sampled through a pipe has told you about the pager.').
doctrine(editing_a_shell_script_corrupts_the_instances_already_running_it,'bash reads a script INCREMENTALLY rather than loading it whole, so editing a file while an instance is executing it shifts the byte offsets underneath that instance. Two background pr-reviews watchers, started ten minutes earlier and sleeping in their poll loops, both died with \"syntax error near unexpected token do\" the moment the orchestrator appended a paragraph to the usage text -- and the script itself was syntactically fine, which bash -n confirmed. The failure is in the RUNNING instance, not the file, so it is invisible to every check you would think to run afterwards. Before editing a script, ask what is still executing it; a long-poll watcher is exactly the shape that will be. Worse in this case than a crash: the watcher exited having printed only the EARLIER review, so the wait it was supposed to perform silently did not happen.').
doctrine(a_worktree_agent_merges_the_orchestrator_uncommitted_local_main,'Agents in git worktrees share the repository local refs, so \`git merge main\` in an agent worktree takes the ORCHESTRATOR LOCAL main -- including commits never pushed to any remote. That is how an unlisted context/code/pr-review.md, committed straight to local main by the orchestrator, reached PR #98 branch and turned its build job red on the doc-site manifest gate (improvise-res9 working exactly as designed). The agent diagnosed it as \"main has been red since PR #96\", and BOTH halves were wrong: CI on #96 merge was green, and the doc was not in main when #96 agent merged at ea206bf -- it entered between, so only the LATER branch saw it. The orchestrator local main is an unreviewed publishing channel into every running agent, and a commit there is subject to the same gates as a PR without getting them. Either run the gates before committing to local main, or do not commit there at all.').
doctrine(a_manifest_gate_fires_at_merge_time_not_at_authoring_time,'scripts/build-doc-site.mjs refuses a context/**/*.md that is neither published nor a named omission -- a good gate (improvise-res9). But it runs in the \`build\` CI job, so a doc added in one branch makes every LATER branch red while the author who added it never sees a failure. The cost lands on whoever merges next, who then diagnoses somebody else change. Worth moving to the \`test\` job, which is fast and needs no wasm build. Generalises: a gate over a REPOSITORY-WIDE invariant should run wherever the violation is authored, not only where the tree is assembled.').
@@ -364,7 +366,6 @@ doctrine(a_hard_floor_across_widths_names_the_container_not_the_child,'improvise
doctrine(never_hide_the_overflow_you_are_asserting_the_absence_of,'improvise-tg27 rejected body{overflow-x:hidden}, which would have made scrollWidth == clientWidth UNCONDITIONALLY and rendered its whole spec vacuous -- every assertion still passing, the bar still not fitting. The check would then answer \"is overflow hidden\" where it means \"does the content fit\". A fix that makes the measurement unable to fail is not a fix, and it is especially tempting in CSS, where suppressing a symptom and correcting a layout look equally like one line.').
doctrine(a_deferral_cost_must_be_measured_not_named,'improvise-k844 sat open because context/code/gp.md section 7 deferred it on the claim that reordering the arithmetic \"moves the bit patterns misclass_rate_and_importance_are_bit_for_bit_what_they_were pins\". That test pins permutation_importance RAW SCORES; importance_ranking consumes them AFTERWARDS, so no path it witnesses runs through the share -- and scoring_known_answers.rs is byte-identical across the actual fix, verified by the orchestrator as absent from the diff. A COST PREDICTED FROM A TEST NAME RATHER THAN FROM WHAT IT CALLS is how a one-line fix stays filed, and it reads as diligence, which is why it survives review. Measure the claimed cost or state none. Pairs with an_invariant_is_made_unrepresentable_not_validated: the clamp improvise-pv3g added instead was a call-site check that left inf% in the data behind it.').
doctrine(a_reported_number_can_overflow_while_every_input_is_finite,'The gp crates learned this twice. standardize sums (t-mean)^2 in f32 (improvise-pv3g) and importance_ranking scaled a share by 100.0* in f32 (improvise-k844), and the second was REACHABLE PAST THE FIRST. Both are fixed by making the arithmetic incapable of it -- a Scaling that refuses a non-finite part, an f64 accumulator -- never by a call-site check, and pv3g own f32 clamp WAS that call-site check: it stopped the abort and left inf% in the data behind it. The silent twin is the half to look for: pv3g had cv_regress pooling a fold it could not standardize, k844 had an f32 total going inf with no panic to announce it. Ask what ELSE consumes the derived quantity.').
doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\".').
doctrine(an_observation_is_a_function_of_the_trajectory_not_the_state,'improvise-ilvl claimed \"an observation is a pure function of state\". It is not -- layout_version, cursor_epoch and model_state.dirty all differ between two routes to an IDENTICAL document (0/0/false against 2/2/true). The weaker true claim, that they are reproducible FROM THE LOG, is all replay needs. But the stronger false one is exactly what would justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that is wrong silently in the direction that looks like it works: Basis puts document_generation and layout_version ON THE WIRE and clients compare them for EQUALITY, so a compacted replay is not basis-compatible with any client holding a claim, and dirty is the worker OPFS persist trigger so the same document reached by a different route persists a different number of times.').
doctrine(a_determinism_check_can_pass_because_the_divergent_path_is_unreachable,'Three identical replays of a 26-line script fixture proved the script path deterministic and proved NOTHING, because the fixture cursor moves were silent no-ops (improvise-6uva) -- the divergence had no way to occur. The defect surfaced only when clear-cell FAILED TO PARSE for want of coordinates. Sharper form of \"a green suite is not evidence\": a passing determinism or idempotence check must be shown to be capable of FAILING, by driving the input the check exists to discriminate. It is also how one defect MASKS another -- improvise-up8g viewport hazard is invisible in improvise script precisely because 6uva discards the cursor, so fixing 6uva makes up8g live and they must be closed together.').
doctrine(an_injected_fault_is_not_reverted_until_the_full_suite_is_rerun,'An agent injected two rivals and reverted one onto the WRONG OCCURRENCE: replace(\".is_measure()\",\".is_index_or_dim()\",1) hits the FIRST match in the file, which was drill_constraint pre-existing predicate rather than the injected one. The commit shipped with TWO faults where it had demonstrated one. Every gate re-run afterwards PASSED over the broken logic -- clippy, fmt, check-evidence, both node suites, the eight-crate wasm gate, the full Playwright run -- because the one gate that could see it, cargo test --workspace, had been measured BEFORE the injections and never again. The PR body therefore carried a figure that was checkable and false. Caught by ChatGPT Codex review. THE RULE: the targeted test going green says nothing about what the revert did on its way past, so the full suite must be re-run after every revert.').
@@ -383,6 +384,10 @@ doctrine(every_agent_appends_to_the_same_evidence_file_at_eof,'Four evidence-fil
doctrine(a_family_of_bugs_means_the_question_is_wrong,'improvise-lhi4, improvise-0o6x and improvise-6qet are three beads about the same missing way back out of a collapsed group. The first two both fixed the header VISIBILITY -- and AxisEntry doc comment promise that a GroupHeader is always visible \"so its label and collapse state show\" became true in both halves without the header becoming ADDRESSABLE. Three people re-finding one gap is evidence that each fix answered a NEIGHBOURING question, and the cure was to ask what the user actually expected (the cursor follows the content) rather than to fix a third symptom. So: when a third ticket arrives in one family, stop fixing and re-derive the question -- and expect the answer to be a shape none of the accumulated options list, because the options were all generated under the wrong framing.').
doctrine(a_stub_must_fail_in_the_shape_of_the_real_fault,'A test double that breaks MORE than the real fault breaks satisfies the test through a DIFFERENT arm and reads exactly like coverage. Found three times on improvise-b4ks, each time only by injecting a rival: a cargo stub that failed for EVERY argument also broke cargo metadata, so the run exited on the metadata arm and the rustfmt probe could be DELETED with the suite green. The stub has to fail where the real thing fails and succeed where it succeeds; otherwise the assertion is answered by a path that will never occur in production.').
doctrine(a_checker_fixture_must_carry_the_structure_the_hazard_needs,'A simplified fixture tests a repository that does not exist, and a green suite over one is the same non-evidence as a vacuous assertion. On improvise-b4ks the fixture was vacuous three times: with no PATH DEPENDENCY, cargo fmt --all had nothing to leak through; with a VIRTUAL outer manifest, the bare --manifest-path form happened to reach every member. Each time the structural feature the hazard depends on had been simplified away, and only an injected rival showed it. So enumerate what the hazard NEEDS -- here two workspace roots, a path dependency crossing them, and an outer root that is itself a package -- and build the fixture to carry all of it.').
doctrine(a_pipe_discards_the_exit_code_you_meant_to_read,'FOURTH occurrence in one campaign, and the last one was in a tool written TO BE the reliable answer. scripts/pr-watch once read tr exit status through a pipe. An orchestrator probe reported EXIT=0 from a checker that had exited 1, because the pipeline ended in tail. Another read PIPESTATUS in zsh, where the array is lowercase pipestatus. And scripts/pr-reviews --wait correctly exited 1 on a timeout while the invocation \`... | tail -20; echo\` reported 0 -- so a review window that had EXPIRED read as a review that had ARRIVED, which is the dangerous direction. In bash and zsh the exit status of a pipeline is the LAST command, so any \`cmd | head\`, \`| tail\`, \`| grep\` discards it. Redirect to a file and read the file, or check PIPESTATUS[0] in bash / pipestatus[1] in zsh -- and note the two shells differ, which is its own trap. A gate whose status you sampled through a pipe has told you about the pager. The gate most often lost this way since is scripts/fmt-workspaces.sh: an unformatted tree exits 1 AND PRINTS A DIFF, so \`| tail\` shows the tail of a diff and exits 0, which reads as a clean run with some output. Read its status, or run it with --fix.').
doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\". CHANGED 2026-09-24 (#287, #297): every CI step now runs inside nix develop .#ci, itself a pkgs.mkShell, so CI exports those variables too and the dev shell no longer masks a CI failure of this shape. The hazard moved rather than vanished: the environment that differs now is a BARE local shell with no direnv, and checkers.sh still scrubs to an allowlist so a suite cannot come to depend on one of them either way.').
doctrine(checkout_ours_or_theirs_drops_the_other_sides_clean_changes,'git checkout --ours or --theirs on a conflicted file takes the WHOLE file from one side, so it silently drops every non-conflicting change the other side made to that file. #245 resolved a ci.yml conflict with #228 by --theirs and lost its own check-harness-lock --allow-network step while its merge message said the step was unchanged (improvise-dndu). No gate saw it: check-ci-graph.py counts suites, not that step, and a message cannot be checked at all. Resolve by hand, then diff the merge against the merge base and against EACH parent -- the question is not whether git combined the texts but whether the result holds both sides.').
doctrine(a_clean_merge_of_one_test_file_can_still_fail_together,'Two branches can each pass CI, merge with no conflict, and fail together. #287 found that on the NixOS runners /usr/bin:/bin holds only env and sh, and gave section 5 of checkers-test.sh a hermetic utilbin PATH; #237, cut earlier, added section 5b still using PATH=...:/usr/bin:/bin. The merge was textually clean and 5b broke every PR after it (#296). A fix to an IDIOM -- a hermetic PATH, a scrubbed environment, a helper every case must call -- is owed by every case in the file, including cases that exist only on another open branch. After a merge, grep the merged file for the old idiom, and run the gates on the merged tree rather than on each side.').
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
@@ -530,8 +535,6 @@ decision('improvise-xgra',an_inline_comment_at_the_head_satisfies_the_wait,'A pu
decision('improvise-0ygd',split_on_the_buffer_not_the_column,'In records mode +measure is ALWAYS the rightmost column, so cursor.col >= col_max names that one cell and nothing else -- which means the entire \"Tab at the bottom-right starts the next record\" bulk-entry contract is reachable ONLY from that cell with an EMPTY buffer. A guard keyed on the column alone fixes improvise-0ygd and SILENTLY DELETES BULK ENTRY. The injected rival proves it: dropping the value-awareness passes the new test and fails tab_on_an_empty_new_measure_column_still_starts_a_record_row. When one cell is the only member of a class, a guard on the class is a guard on the cell.').
decision('improvise-nxah',a_refused_frame_is_rolled_back_by_the_fold_not_a_cache,'What rolls back on ServerFrame::Rejected is ViewState -- the thing the fold produces -- and never a cache, because a Command::Model is A NO-OP IN reduce_view: the optimistic fold never touches a cache at all, and page_coords moves only under Projection::Grid. That single fact refutes the bug report own evidence (which blamed page_coords) and dissolves the wave recorded contradiction between nxah and improvise-3al1 -- the staged typing 3al1 needs rides in SetBuffer frames, which carry no model command, are never refused, and have already settled, so rolling back the COMMIT restores the buffer TO THE TYPING. Only a frame carrying a Command::Model is rejectable, stated exhaustively by Checked::against.').
decision('improvise-tg27',a_reserve_is_a_floor_and_must_reflow_not_widen,'header{flex-wrap:wrap} over a @media breakpoint: a flex item wraps exactly when the line cannot hold it, so the desktop bar is untouched BY CONSTRUCTION rather than by a chosen number -- no magic width to drift. Plus main{min-width:0}, 40vw caps on the side panels, and max-width caps so a reserve cannot exceed its own line. The reflow threshold is deliberately asserted NOWHERE, because #status composes the DOCUMENT NAME: the same 1024px window is one line under a short name and two under a long one. Only 1280 is pinned, being where improvise-n6t8 stability spec runs.').
decision('improvise-f9qf',one_checker_entry_point,'scripts/checkers.sh is the ONE entry point for every check_the_checker, called verbatim by CI and by the local gate so there is no second invocation to drift. It pins a bash >= 5 (ambient first, nix only when the ambient one is older, exit 3 when neither), SCRUBS THE ENVIRONMENT to an allowlist (env -i plus PATH/HOME/TMPDIR/NIX_*/GITHUB_*, extensible via CHECKERS_KEEP), and refuses any shell shebang in scripts/ that is not #!/usr/bin/env bash. The pinned bash goes on the FRONT of PATH so scripts the suite invokes get it too -- which is where the fault actually lived. CI logs \"checkers: bash 5.2 at /usr/bin/bash\": ambient, no nix, zero startup.').
decision('improvise-f9qf',nix_shebang_declined_on_availability_not_speed,'Measured warm, 20 invocations: ambient bash -c true 5.1ms; nix shell nixpkgs#bash --command 61.8ms; nix-shell -p bash --run, THE MANPAGE FORM THE USER POINTED AT, 675.2ms -- 132x, and that inside git merge machinery for beads-merge-driver.sh. But the DECIDER is availability: nix is installed on ONLY the kb job. build, test, wasm, js, harness-lint, browser and pages-deploy have none, so a nix shebang needs install-nix-action added to three more jobs, paid every run, to reach a bash those runners already ship. zsh declined too -- not a shebang change (word splitting, 1-based arrays, globbing), sixteen scripts to read, and no zsh on the runner either.').
decision('improvise-6uva',script_replay_lost_its_context_at_35946af,'improvise script and improvise cmd resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (0,0,0,0) -- never the interactive arm that builds CursorState::from_ctx. So every Move command and enter-advance snapshots a ZEROED cursor AT PARSE TIME, clamps to (0,0) and emits SetSelected(0,0): worse than a no-op, it RESETS the cursor, and no cursor-reading command is reachable from a script. Broken by 35946af (2026-04-04), a performance refactor introducing the register/register_pure/register_nullary split, FIVE MONTHS BEFORE the first wasm-client commit -- so the web refactor is innocent. Both directions demonstrated: at the parent 649d80c a move-then-clear cleared row 2 and a bare clear cleared row 0; at HEAD both clear row 0. THE SHAPE: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.').
decision('improvise-gd28',browser_drill_blocked_by_surrogate_scope_not_layer_2,'The ticket recorded drill as blocked on ADR 001 layer 2 because ApplyAndClearDrill is session-local and unaddressable. That reasoning is about the COMMIT path. Entering a drill needs no commit, and CreateView/SetAxis/SetPageSelection are already unaddressed ModelCommands that NAME THEIR VIEW. What a browser cannot supply is the OPERAND -- CellId::Aggregate(AggregateHash), an opaque u128, which is ADR 002 working as designed. The real gap is one level down from where it was looked for: THE MUTATION AND ITS OPERAND HAVE DIFFERENT SCOPES. The mutation configures the shared _Drill and is addressed to nobody; the operand exists only inside one session layout. So the fix is not a new mechanism but the view:String convention every other view-scoped variant already follows, resolved against a layout derived for that name -- no SessionId needed. fdk1 is also not needed: edit_or_drill declines records-mode editing OUT LOUD before reaching a cell, so a browser cannot edit inside a drill grid at all and no divergent staging scheme is reachable.').
decision('improvise-rr8n',keep_the_table_derived_from_what_it_describes,'The ticket asked to rewrite the tile-bar hint literal and SUPPRESS the derived status line in TileSelect. The implementing agent did the opposite and the orchestrator ACCEPTED it: the derived status line is a STRICT SUPERSET of the literal (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting -- the literal read \"Ctrl+arrows to move tiles\" while those keys are bound to enter-tile-select, so it was already false. Both tile-bar literals deleted; Normal derived line already says T:tiles. The import wizard keeps the ticket ORIGINAL answer, and the asymmetry is the point: ITS status line is derived from the MODE keymap while the real keys are per-step inside HandleWizardKey, so there the derived table is the one that lies. RULE: keep whichever table is derived from the thing it describes. COROLLARY: every_hint_key_resolves_in_its_mode CANNOT SEE A STRING LITERAL IN A WIDGET, the same blind spot improvise-wk18 names for help.rs, so a hand-written key literal is invisible to every hint guard in the repo.').
@@ -540,6 +543,22 @@ decision('improvise-6uva',a_registry_with_two_arms_demotes_the_weaker_caller,'Cm
decision('improvise-55v2',a_borrowed_str_in_a_wire_adaptor_narrows_the_format,'Repr::Nonfinite(&\'a str) in an untagged serde adaptor can only be produced by a deserializer that BORROWS FROM ITS INPUT, so the spelling round-tripped through from_str and from_slice and through nothing else. Measured before fixing: {\"Number\":\"inf\"} parsed while {\"Number\":\"in\\u0066\"} -- THE SAME JSON DOCUMENT, since escaping is not a distinction JSON makes -- was refused, and to_value/from_value did not round-trip the adaptor own output. So the wire accepted a SUBSET of the encodings of the spelling it claimed to share with the .improv file. Cow<\'a, str> fixes all four paths and leaves the serialized form unchanged. THE TESTS COULD NOT SEE IT because both used from_str exclusively, which is precisely the one path that borrows -- a check adjacent to the question it names, arriving inside the change that DEFINES the question. Found by adversarial review, confirmed by injection.').
decision('improvise-6qet',a_collapsed_group_is_one_aggregate_row,'USER RULING 2026-09-23: \"When collapsing a group, I would generally expect the cursor to move to whichever place the group collapsed into.\" That reframes the bug -- it is not \"the GroupHeader is not addressable\", it is \"THE CURSOR DID NOT FOLLOW THE CONTENT IT WAS ON\", and the ticket four options were all answers to the wrong question. CHOSEN SHAPE, a fifth none of them listed: expand_category stops SKIPPING a collapsed group items (the bare \`continue\` at layout.rs:755, which is the whole mechanism of the bug) and emits ONE DataItem for the group, whose cell is an Aggregate. It DISSOLVES the problem rather than working around it -- the cursor lands on the collapsed group BY ARITHMETIC with no special case, z becomes an involution again without anything being taught about involution, ViewCursor and row_group_for and every cursor-addressed command are untouched, and settability is already answered by ADR 002 since grid_of returns CellId::Aggregate. It is also what a pivot table MEANS by a collapsed group: the totals in one row, where today collapsing shows nothing in their place. Rejected: a cursor that names a visual entry (literally delivers the ruling but changes row_group_for signature and forces every cursor-addressed command to define a header row), and resolving against the visual stream (narrow, but only ACTS as if the cursor had moved).').
decision('improvise-b4ks',fmt_per_root_by_member,'This repo has TWO workspace roots -- harness/opfs-contract declares its own [workspace] -- and BOTH obvious spellings fail in the direction that looks like success, each measured. \`cargo fmt --all\` (or --manifest-path <root> --all) CROSSES THE BOUNDARY, because --all formats local PATH DEPENDENCIES and harness/opfs-contract depends on improvise-io by path, so the inner root reported src/draw.rs; this is wider than the ticket note, which blamed only cd-then---all. And bare --manifest-path is BLIND TO MEMBERS, since the repo root is a package as well as a workspace -- an injected fault in crates/improvise-core exited 0. The only sound spelling is \`-p <each member>\` scoped by that root own manifest. Note \`cargo fmt -p opfs-contract\` FROM THE REPO ROOT exits 1 with \"not a member of the workspace\", indistinguishable from a real verdict, which is the ticket itself; from INSIDE that root it is correct, which is why CI harness-lint job was always sound while the root-level gate every brief prescribed was checking less than it claimed.').
decision('improvise-f9qf',one_checker_entry_point,'scripts/checkers.sh is the ONE entry point for every check_the_checker, called verbatim by CI and by the local gate so there is no second invocation to drift. It SCRUBS THE ENVIRONMENT to an allowlist (env -i plus PATH/HOME/TMPDIR/NIX_*/GITHUB_*, extensible via CHECKERS_KEEP) and refuses any shell shebang in scripts/ that is not #!/usr/bin/env bash. It used to pin a bash >= 5.1 as well (ambient first, nix only when the ambient one was older, exit 3 when neither), and that half is GONE since #297 (improvise-3uow, 2026-09-24): CI moved to NixOS runners where every step runs in nix develop .#ci, and the user ruled that scripts must not care which bash or standard utilities are present. So the runner calls each suite with $BASH and an unchanged PATH, and a script needing a newer feature fails where it uses it. Which suites each CI job runs is the ci_suites table in checkers.sh (checkers.sh --ci <job>), held to checkers.sh --list by check-ci-graph.py. context/code/tooling.md section 5.').
decision('improvise-f9qf',nix_shebang_declined_on_availability_not_speed,'Measured warm, 20 invocations: ambient bash -c true 5.1ms; nix shell nixpkgs#bash --command 61.8ms; nix-shell -p bash --run, THE MANPAGE FORM THE USER POINTED AT, 675.2ms -- 132x, and that inside git merge machinery for beads-merge-driver.sh. But the DECIDER was availability: when this was decided nix was installed on ONLY the kb job, so a nix shebang needed install-nix-action added to three more jobs, paid every run, to reach a bash those runners already shipped. zsh declined too -- not a shebang change (word splitting, 1-based arrays, globbing), sixteen scripts to read, and no zsh on the runner either. SUPERSEDED IN ITS PREMISE on 2026-09-24 (#287, #297, improvise-3uow): every CI job now runs on a self-hosted NixOS runner inside nix develop .#ci, so bash comes from the dev shell everywhere and no shebang needs to reach for nix. The decline stands, for a different reason: the dev shell already supplies the bash, so a nix shebang would add startup cost and buy nothing.').
decision('pr-landing-2026-09-24',land_a_batch_as_one_integration_pr,'Approved PRs that land together land as ONE integration PR, not one at a time. Every PR appends its evidence at the end of a context/test-evidence file, so each single merge makes every other open PR conflict at EOF, and a conflicting PR gets no CI run at all. On 2026-09-24 the day went through #298 (nineteen PRs) and #299 (the browser-editor stack). This is what one PR per wave already meant; the EOF-append convention is what makes it a necessity rather than a preference.').
decision('adr-process-2026-09-24',an_open_adr_pr_is_watched,'User 2026-09-24: an open ADR PR must be actively watched, so the steward agent acts on the user inline answers and on reviews as they arrive. An answer left unread for hours is a failure. The orchestrator keeps a background poll for non-agent comments and reviews since its last pass, and resumes the steward on each new item. Only the user explicit approval still merges an ADR; the watch is about latency, not authority. bd memory adr-pr-needs-a-watcher.').
decision('improvise-wpxr',a_record_resolves_in_the_view_the_command_names,'A SetCell or ClearCell carries the view that drew it, and its RecordHash resolves only when the document has that view: the store index when the record holds cells, else that view layout, where an EMPTY record still exists as a drawn row. A view the document lacks drew nothing, stored or not, so the answer is RecordGone rather than a resolve_view fallback (Codex P2 on #281). RenameRecordCoordinate resolves in the store alone, since only a record holding cells can be renamed. protocol::named_records says which, per command, as NamedRecord::Drawn or NamedRecord::Stored, one exhaustive match.').
decision('improvise-kimw',grids_memoise_one_layout_per_view_per_borrow,'App::grids() returns a Grids borrow that derives each named view GridLayout at most once for as long as it lives, so a command asking resolve_record, resolve_cell and unwritable of one view no longer re-derives the layout about five times (and, with formulas, clones and recomputes the model each time). The memo is only sound because it is a BORROW: hold one across a check, never across a mutation, since its layouts describe the model it borrowed.').
decision('improvise-e265',group_runs_nest_and_pruning_remarks,'FrameGroup and GroupEnd carry a level, an index into the axis cats, and runs NEST: a header at level L replaces L and closes every deeper run, a GroupEnd at L closes L and deeper, and an item belongs to the innermost open run. So prune_axis_items does not filter markers, which cannot tell an emptied header or an orphaned GroupEnd; it re-marks the survivors, emitting before each kept item exactly the markers that make its open runs equal the input ones at every level (remark, context/code/core.md section 6).').
decision('improvise-4k1j',the_frame_carries_the_layouts_fold_flag,'FrameEntry::Item is { tuple, collapsed }, and collapsed is the layout own fold flag, read by AxisFrame::folds. A client asks the frame whether a position folds; re-deriving it from the nearest group was the bug, which hid a group name under nested row categories.').
decision('improvise-jq0m',spanned_narrowing_chooses_by_index_size_and_keeps_store_order,'Model::fold_stored folds a collapsed group once over every record it covers, through DataStore::spanned_values and spanned_positions (improvise-yb4b). spanned_positions narrows either once by the base key or once per covered item of the smallest span, whichever visits fewer by index-set size; scanning every item of the category was quadratic on a grid of small collapsed groups. Both arms sort their positions, so both return STORE ORDER: a fold in any other order would drift SUM in the last bits, and would make the choice between the arms visible in a total.').
decision('improvise-8xq0',a_commit_writes_the_cell_its_editor_opened_on,'The browser keyboard commit writes ClientState::opened, not the cursor. OpenedCell captures at open the record, the OpenedTarget (a value by measure, or a records coordinate by category), the record version, the document generation and the view. So the commit lands on the cell it opened on, claims the basis it was opened against rather than one read at commit (improvise-xz2z), and a retry after a crossing LayoutMoved must match the WHOLE OpenedTarget, not the record alone (improvise-8phw, improvise-9ww8). refused_here exempts a captured editor, so the cursor current cell cannot refuse a commit aimed elsewhere (improvise-3h9w).').
decision('improvise-uln5',needs_the_host_is_one_list_held_three_ways,'resolve::NEEDS_THE_HOST is the one list of commands a browser cannot run and answers by sending the key upstream. Three tests hold it from three directions: the_command_names_that_still_need_the_host_are_these (no false entry), a_help_row_the_browser_cannot_run_is_drawn_marked (the help overlay marks them), and every_silent_binding_is_on_the_list, which walks every binding of every mode a browser can reach (improvise-oyaa, improvise-s2gb) because a check read off the help rows could not see a binding no row names. A decline that must stop the rest of a binding is Step::Declined (improvise-joed, improvise-ik6l).').
decision('improvise-n0pb',a_client_notice_queues_behind_in_flight,'ClientState::notify folds a client-side status as an outgoing frame at the tail of in_flight, so a rebase that rebuilds the view from settled and replays in_flight replays the notice too instead of erasing it. settle_status runs on every path that rebuilds the view, including rebase (improvise-0dbk), and gives a new message to the editor open as it arrives; EditorSession, that owner key, lives in improvise-protocol beside ViewState (improvise-su4u, improvise-1x9h).').
decision('improvise-gk51',uneditable_is_one_decline_for_open_and_commit,'cmd::core::uneditable is the one decline for opening an editor (EnterEditAtCursor) and for committing one (CmdContext::committable_cell_key): a fold or other aggregate, a formula result, or CellId::NonItem, a stored value whose coordinate names no item of its category. Asking only at open let Enter-to-advance commit onto a collapsed group row (improvise-enz6), and projecting a NonItem cell as Record made the host refuse a write the projection had offered. It reads the CellId the projection uses, and the browser declined_at matches on that CellId, so both front ends refuse the same cells.').
decision('improvise-i5z6',compare_the_slowest_binary_to_the_next_slowest,'The ratio to the median test binary is degenerate: most binaries print 0.00s at two decimals, so the median is zero and every ratio to it undefined. An outlier is instead a binary that stays well clear of the next few slowest in every run. improvise-formula lib was read that way (longest of 39 in all three runs) and had improvise-8shu own cause: a generator re-parsing its pest grammar per case, fixed by reading it once through a OnceLock (0.84 to 0.98s down to 0.06 to 0.07s).').
decision('pr-landing-2026-09-24',check_base_ref_before_merging,'Before merging ANY pull request, its baseRefName must be main. A stacked PR whose base PR has already merged still targets the stale base branch, and merging it lands the change there, not in main, while the merge reads as done. On 2026-09-24 fifteen approved stacked PRs in #255 to #286 merged into a base whose own PR had already merged; recovery took seven landing PRs (#289 to #295), each from a stack tip against main, whose titles name exactly those fifteen. Four more in that range (#259, #263, #272, #273) merged into a non-main base whose PR was still open and reached main with it, which is stacking working as intended. The repository reported delete_branch_on_merge true when measured that day, and the stale bases were still on GitHub afterwards, so that setting is not the guard: the check is. Retarget with gh pr edit <n> --base main once the base PR has landed, then read the diff again. bd memory stacked-pr-merges-into-stale-base (which says sixteen).').
decision('improvise-3uow',ci_takes_its_tools_from_the_ci_dev_shell,'User rulings 2026-09-24. CI runs on self-hosted NixOS microVM runners, and every run: step runs inside nix develop .#ci through the workflow defaults.run.shell; a uses: step (an action) does not go through a shell at all. A missing tool goes into the .#ci shell in flake.nix, never into a per-step nix shell wrapper. No hard-coded /bin or /usr/bin paths, since those hold only env and sh there. No bash-version floors, no tool-version probes: scripts and tests must not care which bash or standard utilities are present, so checkers.sh lost its bash 5.1 pin and the dev-shell toolchain-pin step left ci.yml (#297). Playwright browsers come from nix via PLAYWRIGHT_BROWSERS_PATH, so there is no browser install step; npm ci stays for @playwright/test. A test that fakes an environment builds it on purpose (a utilbin of linked tools) rather than assuming the runner holds anything. bd memory ci-is-version-agnostic.').
wire_carries(command,both_directions,'user intent up, projection commands down').
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
wire_carries(command,both_directions,'user intent client->server, projections server->client').
@@ -681,7 +700,6 @@ kb_issue_found(static_str_blocks_deserialize,'a borrowed &\'static str field mak
kb_issue_found(ticket_premise_false_8uz,'improvise-8uz asserted selected/row_offset/col_offset were \"PERSISTED in .improv\". They were not: improv.pest has no cursor production and format_md emitted none. The claim shaped the ticket\'s whole option set, so it was checked with a test before any of the options was weighed.').
kb_issue_found(ticket_premise_false_rml,'improvise-rml called category_panel.rs an \"exact copy\" of tile_bar.rs. It was not: the colors were identical but the labels differed (\"Row ↕\" vs \"Row\", \"None ∅\" vs \"·\"). Only the color table was true duplication; the four label sets are four deliberate vocabularies and were preserved as four methods.').
kb_issue_found(duplicated_active_view,'adding ViewState::active_view created a second copy of \"which view is active\". The improvise-8uz characterization test caught the desync immediately because it called workbook.switch_view directly. Held in lockstep by two writers until improvise-jytm, which made the divergence deliberate: the guard test the_two_active_view_names_never_diverge was REPLACED, not deleted, by the_documents_default_view_outlives_this_clients_switches and a_fresh_session_opens_the_documents_default_view -- a deliberate split with no guard is how one silently regresses into the other.').
kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths report success and register nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returns clientMutationId null, and POST /pulls/<n>/requested_reviewers with copilot-pull-request-reviewer[bot] returns 200 with requested_reviewers empty -- and no review_requested timeline event appears. Cause unknown; something changed between #27 and #61. Consequence for the process: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side, and doctrine(an_advisory_gate_is_not_a_gate) with the gate removed rather than never added. AGENTS.md now says review is an adversarial agent the orchestrator dispatches, naming the head SHA, and keeps the Copilot mechanics for the day it returns.').
kb_issue_found(github_build_setup_injects_into_the_wrong_job,'dist github-build-setup is documented as running extra steps before dist own build step, and is the obvious way to give a release job a wasm toolchain. Measured on dist 0.30.4 by injecting a named probe step and regenerating: it lands in build-local-artifacts ONLY -- the four-platform matrix job -- and never in build-global-artifacts, which is where a GLOBAL [[dist.extra-artifacts]] is actually built. So it cannot provision anything for a global extra artifact, and the provisioning must live inside the build command itself. The hook reports success and simply lands elsewhere: nothing distinguishes \"injected somewhere useless\" from \"not injected\", which is doctrine(an_empty_actions_log_is_retention_not_history) in its configuration form. NOTE ON CITING: the proposing agent named that doctrine from memory and invented a plausible key for a fact that exists under a different one -- the kb form of the dead-ticket-ID hazard. scripts/kb refused the delta over it, which is the gate doing its job; grep a key before citing it.').
kb_issue_found(minibufcolor_roles_are_not_what_they_look_like,'MinibufColor Green/Cyan/Yellow was read by the orchestrator as editing/destructive/search. BOTH HALVES ARE WRONG at the eight MinibufferConfig construction sites. Cyan is the FORMULA prompt; there is no search minibuffer at all, SearchMode being Normal plus a flag (gotcha 14) with the search MATCH drawn Black-on-Yellow in the grid. And green/yellow does not split on editing-vs-prompting: item_add is GREEN while category_add is YELLOW, the same act -- typing a name into the document -- on opposite sides. Consequence for improvise-nn4s: its own cheapest proposed step, renaming the three variants to roles, CANNOT BE DONE HONESTLY, because no three-way split fits eight sites; the honest move is a face per text-entry mode, eight faces with three resolving alike today. The general point: a colour-named enum does not merely fail to be themeable, it actively MISLEADS the next reader about what the roles are -- the orchestrator read roles off the colour names and was wrong twice in one sentence.').
kb_issue_found(stale_count_in_tui_md_default_chrome_arm,'context/code/tui.md line 125 says mode_style spells out \"the fourteen variants that take the default chrome\". The arm lists THIRTEEN -- verified by the orchestrator against src/draw.rs: Normal, RecordsNormal, FormulaEdit, FormulaPanel, CategoryPanel, CategoryAdd, ItemAdd, ViewPanel, ImportWizard, ExportPrompt, SavePrompt, Help, Quit. 13 + Editing + RecordsEditing + CommandMode + TileSelect is the 17 AppMode variants, so the arithmetic confirms thirteen. Nothing checks a count written in prose, which is why it survived; the exhaustive match itself is sound and only the sentence about it is wrong.').
@@ -698,6 +716,7 @@ kb_issue_found(a_layout_measured_before_the_status_settles_is_not_the_layout,'im
kb_issue_found(check_evidence_cannot_see_a_templated_test_name,'scripts/check-comment-runs.py --check-evidence scrapes LITERAL test(\"...\") strings out of *.spec.js, so a Playwright test named by a template literal -- test(\`at ${width}px ...\`) -- is invisible to it and its evidence heading reports as an orphan. improvise-tg27 hit this and restructured to five literally-named tests, each looping widths internally with the width in the assertion message. Parameterise INSIDE one literally-named test rather than looping test().').
kb_issue_found(k844_description_was_verbatim_e8no_body,'improvise-k844 stored description was a verbatim copy of improvise-e8no body -- the wide-numeric-category budget -- contradicting its own title. Confirmed by diffing the two DESCRIPTION blocks: identical. The agent worked the TITLE, which was right, and the defect had one good consequence: reading e8no text while working k844 is what made it check whether e8no was done, and e8no turned out to be STALE-OPEN with both its guard tests passing. A ticket carrying the wrong body is worse than an empty one, because an agent that trusts it works the wrong problem while believing it has a specification.').
kb_issue_found(the_old_checker_gate_overrode_every_shebang,'The gate in the orchestrator brief -- \`for t in scripts/*-test.sh; do sh \"$t\"; done\` -- OVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash 3.2 in sh-mode regardless of what the file declared. The gate was the disease it was meant to catch. Seven further sites ran a repo script through an explicit \`sh\`, and ONE OF THEM IS dist-workspace.toml, which a first-line shebang gate STRUCTURALLY CANNOT SEE: the shebang rule closes DECLARATIONS and never INVOCATIONS, and that one was found by grepping for the CALL after fixing the declaration, not by the gate going green. Also: scripts/node-test.sh matches *-test.sh and is a test RUNNER, so a naive glob ran it with no arguments and it printed its usage INSIDE A GREEN GATE -- the rule is now that a suite is X-test.sh where X or X.sh exists beside it.').
kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths reported success and registered nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returned clientMutationId null, and POST /pulls/<n>/requested_reviewers with copilot-pull-request-reviewer[bot] returned 200 with requested_reviewers empty -- and no review_requested timeline event appeared. Consequence for the process at the time: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side. THE LAPSE ENDED: on 2026-09-24 Copilot posted inline findings on #221, #251, #274, #295 and #298, so this issue is history, not the present. AGENTS.md now says its findings are read and answered like Codex, through scripts/pr-reviews because most arrive as inline comments that gh pr view --json reviews does not show, and still that nobody waits on it: the review that gates a merge is an adversarial agent given the head SHA.').
functional(invariant/2).
functional(location/3).
functional(doctrine/2).
+12 -8
View File
@@ -87,28 +87,32 @@ fixture_file() {
}
# `gh api --paginate` emits one JSON array PER PAGE, so a comment thread past
# the page size arrives as several concatenated documents that --argjson
# rejects. Both paths flatten through here, so a fixture can carry two pages.
# the page size arrives as several concatenated documents that combine
# would misnumber. Both paths flatten through here, so a fixture can carry two pages.
pages_to_array() { jq -s 'add // []'; }
# The three documents reach jq on STDIN, never as --argjson: a thread past
# ARG_MAX is exit 126 before jq runs (improvise-5jxt). printf is a builtin.
combine() {
printf '%s\n%s\n%s\n' "$1" "$2" "$3" | jq -s \
'{head: (.[0].headRefOid // ""), reviews: (.[0].reviews // []),
inline: .[1], issue: .[2]}'
}
fetch() {
if [ -n "${PR_REVIEWS_FIXTURE_DIR:-}" ]; then
local n; n=$(fixture_poll)
local pr; pr=$(fixture_file "$n" pr)
local inline; inline=$(fixture_file "$n" inline | pages_to_array)
local issue; issue=$(fixture_file "$n" issue | pages_to_array)
jq -n --argjson pr "$pr" --argjson inline "$inline" --argjson issue "$issue" \
'{head: ($pr.headRefOid // ""), reviews: ($pr.reviews // []),
inline: $inline, issue: $issue}'
combine "$pr" "$inline" "$issue"
return
fi
local repo; repo=$(gh_ repo view --json nameWithOwner -q .nameWithOwner)
local pr; pr=$(gh_ pr view "$PR" --json headRefOid,reviews)
local inline; inline=$(gh_ api "repos/$repo/pulls/$PR/comments" --paginate | pages_to_array)
local issue; issue=$(gh_ api "repos/$repo/issues/$PR/comments" --paginate | pages_to_array)
jq -n --argjson pr "$pr" --argjson inline "$inline" --argjson issue "$issue" \
'{head: ($pr.headRefOid // ""), reviews: ($pr.reviews // []),
inline: $inline, issue: $issue}'
combine "$pr" "$inline" "$issue"
}
# `line` is null once the line has moved under the comment; `original_line` is
+26
View File
@@ -291,6 +291,32 @@ prints "a comment on the first page survives pagination" "$d" 'page1\.rs:1'
prints "a comment on the second page survives pagination" "$d" 'page2\.rs:2'
run_case "a paginated thread still satisfies --wait" 0 "$d" --wait 1 --interval 1
# ---------------------------------------------------------------------------
# A thread past ARG_MAX (improvise-5jxt). PR #210's comments outgrew the argv
# limit, and a fetch that hands the JSON to jq as an argument dies with exit
# 126 before printing anything. 2MB per surface clears every platform's limit,
# and EACH surface is large, so a fix that moves only one of them off argv fails.
d="$work/huge"
mkdir -p "$d"
big=$(head -c 2000000 /dev/zero | tr '\0' x)
{ printf '{"headRefOid":"%s","reviews":[{"state":"COMMENTED",' "$HEAD"
printf '"submittedAt":"2026-09-22T09:00:00Z","author":{"login":"owner"},'
printf '"commit":{"oid":"%s"},"body":"BIGREVIEW %s"}]}\n' "$OLD" "$big"
} > "$d/last.pr.json"
{ printf '[{"created_at":"2026-09-22T10:00:00Z","user":{"login":"owner"},"commit_id":"%s",' "$HEAD"
printf '"path":"big.rs","line":1,"body":"%s"},\n' "$big"
printf '{"created_at":"2026-09-22T10:00:01Z","user":{"login":"owner"},"commit_id":"%s",' "$HEAD"
printf '"path":"after.rs","line":9,"body":"AFTERTHEBIGONE"}]\n'
} > "$d/last.inline.json"
printf '[{"created_at":"2026-09-22T11:00:00Z","user":{"login":"owner"},"body":"BIGISSUE %s"}]\n' \
"$big" > "$d/last.issue.json"
unset big
run_case "a thread larger than ARG_MAX is still read" 0 "$d"
prints "a comment after a multi-MB one is printed" "$d" 'after\.rs:9 commit=aaaaaaaa <- CURRENT HEAD'
prints "a multi-MB review body is printed" "$d" 'BIGREVIEW x'
prints "a multi-MB issue comment is printed" "$d" 'BIGISSUE x'
run_case "a thread larger than ARG_MAX still satisfies --wait" 0 "$d" --wait 1 --interval 1
# ---------------------------------------------------------------------------
# Usage is exit 4 and must stay there: briefs distinguish it from 1.
rm -f "$work/empty/.poll"