From a55b3c71474e4c7b7a29f651466babd64bacc0e0 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:46:03 -0700 Subject: [PATCH 01/11] kb: refuse a write that leaves the kb short of HEAD; publish by rename scripts/kb add wrote onto a working-tree kb.pl that had lost 276 of its 801 clauses and reported success, because kb_consistent checks consistency, not completeness (improvise-usus). Every write now runs kb-merge.pl `complete` on the candidate before kb_consistent. It refuses unless every clause HEAD has is still present by key, or its removal was recorded by `kb rm` in a per-worktree ledger in the git dir, which prunes itself on every write. The candidate is now built beside kb.pl, so publishing it is a rename(2) rather than a possible cross-device copy that a kill could truncate. Exit codes: 0 done, 1 refused, 2 usage, 3 HEAD's kb.pl unreadable. kb-test.sh now builds its fixtures from HEAD and adds cases 18-22. Argument in context/code/kb-tool.md, evidence and rivals in context/test-evidence/tooling.md. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/kb-tool.md | 59 ++++++++++++++++ context/test-evidence/tooling.md | 48 +++++++++++++ scripts/kb | 70 ++++++++++++++++--- scripts/kb-merge.pl | 37 +++++++++- scripts/kb-test.sh | 115 ++++++++++++++++++++++++++++++- 5 files changed, 316 insertions(+), 13 deletions(-) diff --git a/context/code/kb-tool.md b/context/code/kb-tool.md index ae881c5d..92ddafe6 100644 --- a/context/code/kb-tool.md +++ b/context/code/kb-tool.md @@ -83,8 +83,67 @@ argument but the last, so `doctrine(Name,_)` keys on the name and through, and it overwrites in place — a count that grew would be two clauses under one name. +**A write may not end up short of HEAD** (improvise-usus). `kb add` wrote four +facts onto a working-tree `kb.pl` that had already lost 276 of its 801, and +reported success: `kb_consistent` asks whether the facts are *consistent*, never +whether they are all *there*, and a knowledge base missing a prefix of a +predicate is perfectly consistent. `kb-merge` printed `529 before` beside a last +known 805 and nothing compared them. The cause was never settled between a +`kb replace` killed mid-write and git being moved under the session, so the +guard had to catch both, and only a comparison against something *outside* the +working tree can: a guard that compares the file against its own pre-write +count passes a file that was truncated before the write began, which is exactly +what happened. So every write runs `kb-merge.pl complete` on the candidate, +before `kb_consistent`, against `git show HEAD:prolog-sessions/kb.pl`, and +refuses unless every clause HEAD has is still present **by key**. It runs first +so that the refusal names the loss rather than whatever consistency error the +loss happens to cause. + +It compares keys rather than counts, although the ticket asked for a count, +because a count can be masked: uncommitted adds in one predicate offset a +truncation in another, and the count comes out even. Keys cost the same (0.26s +for a write, most of it trealla start-up) and the message still names both +totals and the number missing. + +**How the count legitimately falls.** `add` never lowers it and `replace` +overwrites in place, so neither can trip the guard. `kb rm` is the only +intended removal, and it records the keys it removed in a **ledger** that later +writes read: a clause missing from the working tree is excused only if its key +is in the ledger. The ledger lives in the git dir (`git rev-parse --git-path +kb-removed.pl`), so it is per-worktree and can never be committed; with +`KB_FILE` set it sits beside that file. It prunes itself on every write, keeping +only keys HEAD still has and the candidate does not, so an entry drops once the +removal is committed, and once the key is re-added. That second case matters: +without it, a re-added clause that was lost again would still be excused. There +is deliberately no override flag. The way out of a refusal is to restore +`kb.pl` from HEAD and re-apply, or to commit the removal so HEAD moves. + +**The write is a rename.** The candidate used to come from `mktemp` in +`$TMPDIR`, and `mv` from there onto the repository crosses a device wherever +`/tmp` is its own filesystem. That makes `mv` a copy and an unlink, so a kill +partway through leaves a truncated `kb.pl`. The candidate is now a sibling of +`kb.pl` (`kb.pl.cand.XXXXXX`), so publishing it is `rename(2)`. The ledger is +renamed first. A kill between the two renames then leaves a ledger naming keys +the kb still has, and the next write prunes those entries. Until it does, they +would excuse the loss of exactly those clauses, which is a narrower hole than a +kb.pl that is already short. + +**Exit codes.** 0 done, 1 refused, 2 usage, 3 HEAD's `kb.pl` could not be read, +127 no `tpl`. Only 1 is a verdict. 3 is separate because "could not compare" +is not "compared and refused" (improvise-b4ks). A pre-existing wart survives: +failing to acquire the lock exits 1 too. + ## What no test here reaches +The completeness guard trusts HEAD. A loss that has been **committed** is +invisible to it by construction. That was the right trade for this incident, +where HEAD held all 801, but a bad commit of `kb.pl` becomes the new floor. + +A `SIGKILL` leaves its `kb.pl.cand.*` and `kb.pl.lock` behind, because no trap +runs. The lock already had that property. The stray candidate is new: it is +never read, but it shows as untracked in `prolog-sessions/`, where a `git add +-A` would pick it up. + The key rule is looser than `functional/1` for `crate/3`, which keys on the first argument alone; `kb_consistent` is the backstop for that case rather than the merge. diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 5d2b0a1e..38c28214 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -438,3 +438,51 @@ same way: the suite passed *under a rival*. The generalisable rule is that a stub must fail in the **shape** of the real fault, not merely fail — a stub that breaks more than the real thing breaks satisfies the test through the wrong arm, and reads exactly like coverage. + +### The kb tool's completeness guard and atomic write — `scripts/kb-test.sh` cases 18–22 + +improvise-usus: `scripts/kb add` wrote onto a working-tree `kb.pl` that had lost +276 of its 801 clauses, and `kb_consistent` passed. The argument for the fix +(compare with HEAD by key, record `kb rm` removals in a ledger, publish by +rename) is in `context/code/kb-tool.md`. This entry covers what each case was +watched to catch. + +**Reproduced at `40b8913` before any fix.** A copy of HEAD's `kb.pl` with 34 of +its 35 `location/3` facts removed still passes `kb_consistent`, and +`kb add` onto it printed `775 before, 1 in delta, 776 after` and exited 0. +Against the unfixed tool, the new cases gave 33 passed, 5 failed: 18, 19, 20b, +21 and 22 failed. Case 20 passed there because an rm followed by an add has no +guard to trip. + +**The fixture has to fail in the incident's shape.** The ticket's "truncate to +half" fixture also breaks `kb_consistent`, because the rules live in the second +half. So the unfixed tool refused it too, by the wrong guard. Removing a whole +predicate is an existence error, and removing cited clauses fails the citation +check. Case 18 keeps one `location/3` fact and asserts that the fixture passes +`kb_consistent` before using it. Case 19 keeps the ticket's half-truncation, and +`said()` is what makes it evidence: the completeness guard runs first and must +be the one that answers. + +**Rivals injected into `scripts/kb`, one at a time. Each failed only its own +case:** + +- baseline taken from the file's own pre-write state rather than HEAD: 18, 19 + and 20b failed (35/38 passed). This is the guard that would have passed the + real incident. +- ledger never consulted: 20 and 20b failed. A legitimate rm then blocks every + later write. +- guard stands down whenever the ledger is non-empty: 20b failed. +- candidate built by plain `mktemp` in `$TMPDIR`: 22 failed on the directory + check. +- candidate copied over `kb.pl` before the publish point: 22 failed as + modified. +- unreadable baseline exiting 1 rather than 3: 21 failed. + +**A rival can fail wider than its fault.** The first form of the "stands down" +rival skipped `merge complete` altogether. That also skipped writing +`$cand.ledger`, so the later `mv` crashed under `set -e` and five unrelated +cases failed. It proved nothing until it was rewritten to carry the ledger +forward. + +**Checked by hand, not in the suite:** the ledger prunes an entry once the key +is re-added and once HEAD stops having it (a committed removal). diff --git a/scripts/kb b/scripts/kb index 68998f01..bd029e86 100755 --- a/scripts/kb +++ b/scripts/kb @@ -9,6 +9,8 @@ KB="${KB_FILE:-$repo/prolog-sessions/kb.pl}" # Test seam: kb-test.sh injects a pre-fix kb-merge.pl to prove the arithmetic # check catches a merge that drops a clause. MERGE="${KB_MERGE:-$here/kb-merge.pl}" +# Exit codes: 0 done, 1 REFUSED (a verdict on the write), 2 usage, 3 the +# completeness baseline could not be read, 127 no tpl. Only 1 is a verdict. # trealla comes from the dev shell, which direnv puts on PATH. Reaching it # through `nix shell` per invocation cost more than the work it did: this @@ -62,7 +64,39 @@ acquire_lock() { fi sleep 0.1 done - trap 'rmdir "$KB.lock" 2>/dev/null || true' EXIT + trap 'rm -f "${scratch[@]}"; rmdir "$KB.lock" 2>/dev/null || true' EXIT +} + +# Every temporary a writing command makes. The candidate lives BESIDE the kb, so +# publishing it is a rename(2): a mv from $TMPDIR may cross a device and copy. +scratch=() +new_cand() { + cand="$(mktemp "$KB.cand.XXXXXX")" + scratch+=("$cand" "$cand.delta" "$cand.removed" "$cand.ledger" "$cand.base") +} + +# What `kb rm` removed that HEAD still has: the record that tells an intended +# removal from a lost clause. A fixture's sits beside it; the real one in .git. +ledger_path() { + if [ -n "${KB_FILE:-}" ]; then echo "$KB.removed" + else git -C "$repo" rev-parse --path-format=absolute --git-path kb-removed.pl + fi +} + +# The committed knowledge base, which no write may end up short of. +# KB_BASELINE is the test seam, naming a file. +baseline_to() { + if [ -n "${KB_BASELINE:-}" ]; then cp -f "$KB_BASELINE" "$1" + else git -C "$repo" show HEAD:prolog-sessions/kb.pl > "$1" 2>/dev/null + fi +} + +# Test seam: SIGKILL once the candidate is complete and checked, before it is +# published -- where a killed write dies. Records the candidate's path first. +kill_hook() { + [ -n "${KB_TEST_KILL:-}" ] || return 0 + echo "$1" > "$KB_TEST_KILL" + kill -9 $$ } # Test seam: simulate another writer landing between the read and the commit. @@ -83,14 +117,17 @@ usage: kb replace add clauses, overwriting existing keys rm remove clauses by key; refuses if any key is absent -Every writing command verifies the result reads back as the clauses intended -and passes kb_consistent before it replaces the knowledge base. +Every writing command verifies the result reads back as the clauses intended, +still holds every clause HEAD has that no `kb rm` removed, and passes +kb_consistent before it replaces the knowledge base. + +Exit: 0 done, 1 refused, 2 usage, 3 HEAD's kb.pl unreadable, 127 no tpl. EOF exit 2 } -commit() { # $1 = candidate file, $2 = stamp of the kb before it was read - local cand="$1" was="${2:-}" +commit() { # $1 = candidate, $2 = stamp of the kb as read, $3 = keys a rm removes + local cand="$1" was="${2:-}" removing="${3:-}" ledger race_hook if [ -n "$was" ] && [ "$(stamp_of "$KB")" != "$was" ]; then rm -f "$cand" @@ -98,11 +135,24 @@ commit() { # $1 = candidate file, $2 = stamp of the kb before it was read echo "kb: another writer won; re-run and your delta applies on top." >&2 exit 1 fi + ledger="$(ledger_path)" + if ! baseline_to "$cand.base"; then + echo "kb: cannot read the committed kb.pl to compare against; $KB is unchanged" >&2 + exit 3 + fi + { if [ -f "$ledger" ]; then cat "$ledger"; fi + if [ -n "$removing" ]; then cat "$removing"; fi; } > "$cand.removed" + if ! merge complete "$cand.base" "$cand" "$cand.removed" "$cand.ledger"; then + echo "kb: REFUSED -- clauses HEAD has are gone, with no kb rm recorded; $KB is unchanged." >&2 + echo "kb: restore it from HEAD and re-apply your deltas, or commit the removal." >&2 + exit 1 + fi if ! check_file "$cand"; then - rm -f "$cand" echo "kb: REFUSED -- kb_consistent fails on the result; $KB is unchanged" >&2 exit 1 fi + kill_hook "$cand" + mv -f "$cand.ledger" "$ledger" mv -f "$cand" "$KB" } @@ -138,7 +188,7 @@ case "$cmd" in fmt) acquire_lock was="$(stamp_of "$KB")" - cand="$(mktemp)" + new_cand : > "${cand}.delta" if ! merge add "$KB" "${cand}.delta" "$cand" >/dev/null 2>&1; then rm -f "$cand" "${cand}.delta" @@ -153,20 +203,20 @@ case "$cmd" in [ $# -eq 1 ] || usage acquire_lock was="$(stamp_of "$KB")" - cand="$(mktemp)" + new_cand if ! merge remove "$KB" "$1" "$cand"; then rm -f "$cand" echo "kb: REFUSED -- $KB is unchanged" >&2 exit 1 fi - commit "$cand" "$was" + commit "$cand" "$was" "$1" echo "kb: $KB updated" ;; add|replace) [ $# -eq 1 ] || usage acquire_lock was="$(stamp_of "$KB")" - cand="$(mktemp)" + new_cand if ! merge "$cmd" "$KB" "$1" "$cand"; then rm -f "$cand" echo "kb: REFUSED -- $KB is unchanged" >&2 diff --git a/scripts/kb-merge.pl b/scripts/kb-merge.pl index 580b2d08..2985f912 100644 --- a/scripts/kb-merge.pl +++ b/scripts/kb-merge.pl @@ -10,7 +10,9 @@ main :- -> run(Mode, KbFile, DeltaFile, OutFile) ; Argv = [CountStr, KbFile], CountStr == "count" -> count_only(KbFile) - ; die('usage: kb-merge.pl | count ') + ; Argv = [CS, BaseFile, CandFile, ExcFile, LedgerOut], CS == "complete" + -> complete(BaseFile, CandFile, ExcFile, LedgerOut) + ; die('usage: kb-merge.pl | count | complete ') ). % argv arrives as strings; every later comparison is on an atom. @@ -321,5 +323,38 @@ count_only(KbFile) :- length(Terms, Total), format("total=~w~n", [Total]). +% Consistency is not completeness: a kb that lost 276 clauses still passed +% kb_consistent. Why by key and against HEAD: context/code/kb-tool.md. +complete(BaseFile, CandFile, ExcFile, LedgerOut) :- + terms_of(BaseFile, Base), + terms_of(CandFile, Cand), + terms_of(ExcFile, Exc), + ground_keys(Cand, CandKeys), + ground_keys(Exc, ExcKeys), + findall(T, ( member(T, Base), ground_key(T, K), + \+ memberchk(K, CandKeys), \+ memberchk(K, ExcKeys) ), Missing), + length(Base, NB), length(Cand, NC), + ( Missing == [] + -> ground_keys(Base, BaseKeys), + findall(E, ( member(E, Exc), ground_key(E, K), + memberchk(K, BaseKeys), \+ memberchk(K, CandKeys) ), Kept), + write_terms(LedgerOut, Kept), + length(Kept, NK), + format("kb-merge: complete -- baseline ~w, result ~w, ~w removal(s) recorded~n", + [NB, NC, NK]) + ; length(Missing, NM), + format("kb-merge: REFUSED -- the baseline has ~w clauses and the result ~w; ~w of the baseline's are missing and no kb rm recorded their removal:~n", + [NB, NC, NM]), + forall(( nth1(I, Missing, T), I =< 10 ), format(" ~q~n", [T])), + ( NM > 10 -> Rest is NM - 10, format(" ... and ~w more~n", [Rest]) ; true ), + halt(2) + ). + +ground_key(T, C) :- clause_key([], T, K), canon_key(K, C). + +ground_keys(Terms, Sorted) :- + findall(C, ( member(T, Terms), ground_key(T, C) ), Cs), + sort(Cs, Sorted). + die(Msg) :- format("kb-merge: ~w~n", [Msg]), halt(2). die_file(F) :- format("kb-merge: cannot read ~w~n", [F]), halt(2). diff --git a/scripts/kb-test.sh b/scripts/kb-test.sh index 69e8eed0..52797816 100755 --- a/scripts/kb-test.sh +++ b/scripts/kb-test.sh @@ -5,9 +5,15 @@ set -uo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo="$(cd "$here/.." && pwd)" -KB="$repo/prolog-sessions/kb.pl" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT +# Fixtures are HEAD's kb.pl, never the working tree's: the tool now measures a +# write against HEAD, and the working tree is the thing that can be damaged. +KB="$work/head.pl" +if ! git -C "$repo" show HEAD:prolog-sessions/kb.pl > "$KB"; then + echo "kb-test: cannot read HEAD:prolog-sessions/kb.pl" >&2 + exit 2 +fi pass=0 fail=0 @@ -15,7 +21,8 @@ ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } bad() { printf ' FAIL %s\n' "$1"; fail=$((fail+1)); } # Each case runs against its own copy, so a bug cannot reach the real file. -fresh() { cp "$KB" "$work/kb.pl"; } +# Also clears the ledger, the lock and any candidate a killed case left behind. +fresh() { rm -rf "$work"/kb.pl.*; cp "$KB" "$work/kb.pl"; } kb() { KB_FILE="$work/kb.pl" "$here/kb" "$@"; } unchanged() { diff -q "$KB" "$work/kb.pl" >/dev/null; } # A refusal is only evidence when it is the refusal you NAMED: two of these @@ -500,5 +507,109 @@ else bad "add refused a key holding a variable: the fold matched by unification" fi +echo "kb-test: completeness against HEAD (improvise-usus)" + +# 18. The incident's shape: clauses gone from the working tree and the rest +# still CONSISTENT -- 276 of 801 were lost and kb_consistent passed, so the +# add landed. The fixture must keep kb_consistent green, or a different +# guard refuses it and the case proves nothing: dropping a whole predicate +# is an existence error, and dropping cited clauses fails the citation +# check. So it keeps one location/3 fact and drops the other 34. No +# KB_BASELINE: this case runs the real `git show HEAD` path, which is also +# what a guard counting the file against ITSELF would pass. +fresh +awk '/^location\(/ { if (seen++) next } { print }' "$KB" > "$work/kb.pl" +cp "$work/kb.pl" "$work/lost.pl" +if ! KB_FILE="$work/lost.pl" "$here/kb" check >/dev/null 2>&1; then + bad "the lossy fixture fails kb_consistent -- case 18 proves nothing" +else + kb add "$work/new.pl" >"$work/out" 2>&1; rc=$? + if [ "$rc" -eq 0 ]; then + bad "add wrote onto a kb missing 34 of HEAD's clauses" + elif ! cmp -s "$work/kb.pl" "$work/lost.pl"; then + bad "the loss was refused but the kb was MODIFIED" + elif [ "$rc" -ne 1 ]; then + bad "the loss was refused with exit $rc, not the verdict code 1" + elif said "$work/out" "34 of the baseline's are missing"; then + ok "add refuses a kb that lost clauses HEAD has, and names how many" + else + bad "the loss was refused, but not BY THE COMPLETENESS GUARD" + fi +fi + +# 19. The ticket's own fixture: a copy truncated to half its clauses. This one +# ALSO breaks kb_consistent, so said() is what makes it evidence -- the +# completeness guard runs first and must be the one that answers. +fresh +head -n "$(( $(wc -l < "$KB") / 2 ))" "$KB" > "$work/kb.pl" +cp "$work/kb.pl" "$work/half.pl" +if kb add "$work/new.pl" >"$work/out" 2>&1; then + bad "add wrote onto a kb truncated to half" +elif ! cmp -s "$work/kb.pl" "$work/half.pl"; then + bad "the truncation was refused but the kb was MODIFIED" +elif said "$work/out" "no kb rm recorded their removal"; then + ok "a kb truncated to half is refused by name, byte-identical" +else + bad "the truncation was refused, but not by the completeness guard" +fi + +# 20. rm is how the count legitimately falls, and the ledger is how a LATER +# write knows it: rm a clause HEAD has, then add -- the add must land. A +# ledger that is never written, or is ignored, refuses here. +fresh +printf "location(cli_args,'src/main.rs',_).\n" > "$work/drop-head.pl" +if ! kb rm "$work/drop-head.pl" >"$work/out" 2>&1; then + bad "rm of a clause HEAD has was refused: $(tail -1 "$work/out")" +elif kb add "$work/new.pl" >"$work/out" 2>&1; then + ok "an add after a recorded rm is accepted" +else + bad "an add after a legitimate rm was refused: the removal was not recorded" +fi + +# 20b. ...and the record excuses exactly what it names. A second, unrecorded +# loss on top of a recorded rm must still refuse; a guard that stands down +# whenever a ledger exists passes 20 and fails here. +sed -i.bak '/^location(add_command,/d' "$work/kb.pl"; rm -f "$work/kb.pl.bak" +cp "$work/kb.pl" "$work/twice.pl" +printf "doctrine(another_probe_after_the_rm,'x').\n" > "$work/new2.pl" +if kb add "$work/new2.pl" >"$work/out" 2>&1; then + bad "a recorded rm excused an unrecorded loss beside it" +elif cmp -s "$work/kb.pl" "$work/twice.pl" && said "$work/out" "1 of the baseline's are missing"; then + ok "a recorded rm excuses only the clause it removed" +else + bad "the unrecorded loss was refused, but not by name, or the kb changed" +fi + +# 21. A usage error, an unreadable baseline and a verdict are three exit codes. +# Sharing one is improvise-b4ks: "could not check" reading as "refused". +fresh +KB_BASELINE="$work/no-such-file.pl" kb add "$work/new.pl" >/dev/null 2>&1; rc3=$? +kb add >/dev/null 2>&1; rc2=$? +if [ "$rc3" -eq 3 ] && [ "$rc2" -eq 2 ] && unchanged; then + ok "an unreadable baseline exits 3 and usage exits 2, neither a verdict" +else + bad "exit codes: unreadable baseline $rc3 (want 3), usage $rc2 (want 2)" +fi + +# 22. A killed write leaves the old kb. The tool SIGKILLs itself once the +# candidate is complete and checked, just before publishing it. Two rivals +# this separates: writing into the kb in place (the kb changes), and +# building the candidate in $TMPDIR, where mv may cross a device and copy +# -- so the candidate must be a sibling of the kb, making mv a rename(2). +fresh +KB_TEST_KILL="$work/killed" kb add "$work/new.pl" >/dev/null 2>&1; rc=$? +cand="$(cat "$work/killed" 2>/dev/null)" +if [ "$rc" -ne 137 ] || [ -z "$cand" ]; then + bad "the kill seam did not fire (exit $rc) -- case 22 proves nothing" +elif ! unchanged; then + bad "a write killed before publishing MODIFIED the kb" +elif [ "$(dirname "$cand")" != "$work" ]; then + bad "the candidate was built in $(dirname "$cand"), not beside the kb: mv may copy" +elif ! grep -q '^doctrine(a_name_that_is_certainly_not_taken,' "$cand"; then + bad "the candidate was not complete when the write died" +else + ok "a write killed before its rename leaves the old kb byte-identical" +fi + printf '\nkb-test: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] From ef16723b90ef87cb6028946bbf01f3203a2d04d5 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:47:01 -0700 Subject: [PATCH 02/11] docs(agents): commit frequently, so the work is safe in git User ruling. A worktree is not durable storage: an agent finished a whole ticket with every gate green and lost all of it, uncommitted, when its worktree was deleted during a disk cleanup. The brief template now requires a WIP commit after the first green test and after every meaningful step, and separates "do not merge to main" from pushing the agent's own branch, which is encouraged. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .beads/issues.jsonl | 33 ++++++++++++++++++--------------- AGENTS.md | 13 ++++++++++--- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 59e9930b..d852d360 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,13 +1,15 @@ -{"_type":"issue","id":"improvise-usus","title":"scripts/kb writes onto a truncated kb.pl and reports success: consistency is not completeness","description":"scripts/kb add wrote 4 facts onto a kb.pl that had ALREADY LOST 276 of its 801 facts, and reported success. kb_consistent passed, because it checks that the facts are CONSISTENT, never that they are all still there. The loss was only caught because the orchestrator noticed kb-merge print \"529 before\" where it had last printed 805, and compared the count against HEAD.\n\nLIKELY CAUSE: a background `scripts/kb replace` was killed mid-write when the previous Claude Code process exited -- it completed with exit code -1. So a truncated file was left on disk, and every later `kb add` built on it. Nothing committed was lost (HEAD, origin/main and gh/main all held 801); the working tree was restored from HEAD and the six new facts re-added, giving a diff of 6 insertions and 0 deletions.\n\nTWO FIXES, both cheap, both in scripts/kb:\n1. WRITE ATOMICALLY: write to a temp file in the same directory and rename(2) over kb.pl, so a killed write leaves the old file intact rather than a truncated one.\n2. REFUSE A SHRINK on `add`: an `add` must leave count(after) == count(before) + len(delta). kb-merge ALREADY PRINTS both numbers (\"529 before, 4 in delta, 533 after\") -- the check is to compare `before` against the count in git HEAD and refuse if the working tree has fewer facts than HEAD with no `rm` recorded. The tool reported the symptom in its own output and nothing read it.\n\nCheck_the_checker: truncate kb.pl to half, run `kb add`, and assert it REFUSES. Then kill a `kb replace` mid-write (or simulate by truncating between the two phases) and assert the old file survives.\n\nThis is doctrine(a_prose_count_is_checked_by_no_one) in executable form: a number the tool prints every run and no gate compares.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T03:20:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:20:57Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ktlk","title":"Editing overlay with an absent buffer now SHOWS the cell but Enter still CLEARS it (xg1z fixed the paint, not the commit)","description":"Found by post-merge adversarial review of PR #162 (improvise-xg1z), head b6805232.\n\nThe xg1z fix changed what the overlay DRAWS for an Editing mode that arrived without its `edit` buffer, but not what Enter COMMITS. `render.rs` now shows `edit_seed(state)` (the cell's value, e.g. \"1200\"), but `resolve.rs::commit` still reads `read_buffer(\u0026state.view, \"edit\")`, which is `unwrap_or_default()` -\u003e \"\" -\u003e `typed_cell_command` -\u003e `ClearCell`. Typing one character likewise appends to the absent buffer, so the overlay jumps from \"1200\" to \"5\", not \"12005\".\n\nSo the data loss the ticket describes (\"the next Enter commits the empty string and the value is gone\") still happens. The only change is that the screen now promises the value survives: before #162 a blank overlay at least showed what Enter would commit, and now it doesn't. The display and the commit are two derivations of \"what is in the editor\", and they now disagree.\n\nREPRODUCED at 40b8913 (gh/main), temporary test in resolve.rs tests: `client_on(record_cell())`, fold `ChangeMode(AppMode::editing())` with no SetBuffer, assert `edit_seed == \"1200\"` (passes, which is what render.rs paints), then `resolve(Enter)` -\u003e commands[0] = `Command::Model { cmd: ClearCell(..) }`. Test reverted.\n\nWhy #162's tests could not see it: both tests assert `render_model().editing` only. Neither presses a key. A test of the claim \"the overlay shows the cell rather than a blank\" should also assert that Enter on that overlay commits what it shows.\n\nAlso a fixture-right-by-accident note: `CellDisplay::number(\"1200\")` sets `text == edit_text`, so a fallback that seeded from the formatted `text` (gotcha 8: never feed a rounded value back) would pass `an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank` too. A fixture with text \"1,200.00\" and edit_text \"1200\" separates them.\n\nLikely fix direction: make the buffer the one source, e.g. seed the absent buffer on ChangeMode(Editing) in the fold, or have commit/append read the same seeded value. The fix is not decided here.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T03:46:25Z","created_by":"agent-review-wire","updated_at":"2026-09-23T03:46:25Z","labels":["adversarial-review","browser","data-loss","pr-162"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2bac","title":"commit-cell-edit from a script or : line ignores its value and coords and commits the empty edit buffer to the cursor cell (fourth two-arm demotion, post-6uva)","description":"Found in a post-merge adversarial review of PR #151 (improvise-6uva), head b855faf3. This is a fourth instance of that PR's own lesson (\"a registry with two construction arms demotes the weaker caller\"). The Codex round closed the first three.\n\n`commit-cell-edit` (CommitAndAdvance, AdvanceDir::Down) is registered with `register`, and its two arms mean different things:\n- the pure arm reads `\u003cmode\u003e \u003cvalue\u003e \u003ccoords...\u003e`: value = args[1], key = parse_cell_key_from_args(\u0026args[2..]);\n- the interactive arm reads only args[0] (the mode). It DISCARDS the value and coords, takes the value from the \"edit\" buffer and the key from ctx.cell_key().\n\nSince #151, `improvise cmd`/`improvise script` and the TUI `:` line build every segment through the interactive arm. The whole-line `parse_line` syntax gate still runs through the pure arm, and it accepts the explicit form. So the gate passes and the other arm runs.\n\nMeasured in the headless CLI on a document with Region = North, South, East, where North=1 and East=3:\n\n improvise cmd 'commit-cell-edit editing 5 Region/East' -f a.improv\n\n- pre-#151 (f7573bff): East = 5, North = 1. That is the intended result.\n- gh/main (40b8913): **North is DELETED** and East is still 3. Exit code 0 and nothing on stderr. The empty edit buffer was committed to the cursor cell, which is the origin.\n\nSo a script line that names a cell and a value silently destroys a different cell instead. The failure is partial and silent, and it loses data.\n\nRepair shape: the same one #151 used for clear-cell/yank/paste/drill-into-cell. When args carry a value and coords, the interactive arm must honour them. The regression test should write to a cell the cursor is NOT on and assert that the cursor cell survives.\n\nA sibling of the same shape, not yet run (PLAUSIBLE): `toggle-panel-and-focus \u003cpanel\u003e [open] [focused]`. The pure arm honours explicit open/focused. The interactive arm ignores them and toggles based on current state, so `:toggle-panel-and-focus formula false false` now opens the panel when it is closed. It is view-only and not destructive, but it is the same demotion.\n\nA durable guard worth considering: for every `register` entry, whenever the pure arm accepts an argument vector, the interactive arm must build an equal command from it against a context whose cursor is NOT on the named cell. Today nothing type-checks which arm a caller deserves.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-2bac","owner":"el-github@elangley.org","created_at":"2026-09-23T03:36:16Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T03:43:22Z","started_at":"2026-09-23T03:43:22Z","lease_expires_at":"2026-09-23T03:48:22Z","heartbeat_at":"2026-09-23T03:43:22Z","comments":[{"id":"9dcd479b-8d4b-531d-b3d4-4276559dd846","issue_id":"improvise-2bac","author":"agent-review-cmd","text":"Second symptom of the same root, CONFIRMED at gh/main 40b8913. context/code/cli.md section 7 claims \"Line atomicity is preserved by keeping the existing whole-line parse_line as a syntax gate\". That is false wherever the interactive arm refuses something the pure arm accepted:\n\n improvise cmd 'add-category Foo . commit-cell-edit editing 5 Foo/x' -f e.improv (a new, empty document)\n\nThe output is `Parse error: commit-cell-edit: no cell at cursor`, with exit 1. Category Foo IS written to the file. The gate passed, segment 1 applied, and segment 2 was refused at construction. So half the line landed. Before #151 the same line ran whole. commit-cell-edit is the only entry I found whose pure arm accepts an argument vector its interactive arm can refuse, so fixing the arm disagreement closes this too. The doc claim should be corrected either way.\n","created_at":"2026-09-23T03:39:18Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-usus","title":"scripts/kb writes onto a truncated kb.pl and reports success: consistency is not completeness","description":"scripts/kb add wrote 4 facts onto a kb.pl that had ALREADY LOST 276 of its 801 facts, and reported success. kb_consistent passed, because it checks that the facts are CONSISTENT, never that they are all still there. The loss was only caught because the orchestrator noticed kb-merge print \"529 before\" where it had last printed 805, and compared the count against HEAD.\n\nLIKELY CAUSE: a background `scripts/kb replace` was killed mid-write when the previous Claude Code process exited -- it completed with exit code -1. So a truncated file was left on disk, and every later `kb add` built on it. Nothing committed was lost (HEAD, origin/main and gh/main all held 801); the working tree was restored from HEAD and the six new facts re-added, giving a diff of 6 insertions and 0 deletions.\n\nTWO FIXES, both cheap, both in scripts/kb:\n1. WRITE ATOMICALLY: write to a temp file in the same directory and rename(2) over kb.pl, so a killed write leaves the old file intact rather than a truncated one.\n2. REFUSE A SHRINK on `add`: an `add` must leave count(after) == count(before) + len(delta). kb-merge ALREADY PRINTS both numbers (\"529 before, 4 in delta, 533 after\") -- the check is to compare `before` against the count in git HEAD and refuse if the working tree has fewer facts than HEAD with no `rm` recorded. The tool reported the symptom in its own output and nothing read it.\n\nCheck_the_checker: truncate kb.pl to half, run `kb add`, and assert it REFUSES. Then kill a `kb replace` mid-write (or simulate by truncating between the two phases) and assert the old file survives.\n\nThis is doctrine(a_prose_count_is_checked_by_no_one) in executable form: a number the tool prints every run and no gate compares.","notes":"CAUSE CORRECTED: the user reports having moved git underneath the session at around this time, which is at least as likely as the killed 'kb replace' this ticket first named -- treat the cause as UNKNOWN between the two. That changes the priority of the two fixes: an atomic write only covers the killed-write cause, while REFUSE-A-SHRINK (compare the working-tree fact count against HEAD before any add) catches BOTH, since either leaves a file with fewer facts than HEAD and no rm recorded. Do refuse-a-shrink first.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-kbguard","owner":"el-github@elangley.org","created_at":"2026-09-23T03:20:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:31:13Z","started_at":"2026-09-23T03:31:13Z","lease_expires_at":"2026-09-23T03:36:13Z","heartbeat_at":"2026-09-23T03:31:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6uva","title":"improvise script cannot move the cursor: every Move resolves through the pure-parse arm","description":"`improvise script` and `improvise cmd` resolve every command through CmdRegistry::parse -- the PURE arm, which builds CursorState::default() (row 0, col 0, row_count 0, col_count 0). The interactive arm, which builds CursorState::from_ctx(ctx), is never reached from a script. See command/cmd/core.rs:188 for the two-arm register.\n\nConsequence: every Move command (move-selection, scroll-rows, page-scroll, all four jump-*) and enter-advance snapshots a ZEROED cursor at PARSE time, clamps to (0,0), and emits SetSelected(0,0). It is worse than a no-op -- it RESETS the cursor. There is no way to position the cursor from a script, so every cursor-reading command is permanently pinned to the origin: clear-cell, yank, paste, cycle-axis-at-cursor, hide-selected-row-item, edit-or-drill, drill-into-cell, toggle-group-under-cursor, delete-*-at-cursor, commit-and-advance.\n\nDEMONSTRATED AT HEAD (a9cad15), web/demo.improv, Product on row:\n script A: \"move-selection 2 0\" then \"hide-selected-row-item\"\n script B: \"hide-selected-row-item\"\nBoth documents came out IDENTICAL, both carrying `hidden: Product/Espresso` -- row 0. diff reports IDENTICAL.\n\nBOTH DIRECTIONS DEMONSTRATED. Built the parent of the breaking commit (649d80c) in a worktree and probed:\n \"move-selection 2 0 . clear-selected-cell\" -\u003e cleared Product=Cold (row 2)\n \"clear-selected-cell\" -\u003e cleared Product=Espresso (row 0)\nThe cursor moved there. At HEAD it does not. So neither result is vacuous.\n\nBROKEN BY 35946af (2026-04-04), \"refactor(command): pre-resolve cell key and grid dimensions in CmdContext\", which introduced CursorState, InteractiveFn and the register/register_pure/register_nullary split. Verified ancestry: 35946af is an ancestor of HEAD and of 30c4bb6 (2026-09-19, the first wasm-client commit), so THE REGRESSION PREDATES THE BROWSER WORK BY FIVE MONTHS. The commit message says nothing about scripts.\n\nTHE SHAPE TO REMEMBER: a registry with two construction arms silently demotes every caller of the weaker arm, and nothing type-checks which arm a caller deserves.\n\nREPAIR (not written): run_headless_commands already builds a CmdContext for execute -- it just does not use one for CONSTRUCTION. There is no context-aware script parser today (parse_line_with also calls registry.parse), so closing this needs a parse_line_interactive(registry, line, ctx) calling registry.interactive, plus a PER-COMMAND re-parse inside the loop, since command n must see command n-1's cursor.\n\nA regression test must assert the cursor MOVED, not that the script ran: three identical replays of a fixture whose cursor moves are silent no-ops prove nothing, which is how this survived.\n\nFound by the improvise-ilvl replay audit.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:42Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:45:53Z","closed_at":"2026-09-23T02:17:29Z","close_reason":"PR #151. improvise cmd/script now build each command PER SEGMENT against a live CmdContext (parse::segments + parse::parse_segment_interactive); construction cannot be hoisted, because command n must see n-1's cursor. Line atomicity preserved by keeping the whole-line parse_line as a syntax gate, so the only behavioural change is which cursor the commands see. VERIFIED BY THE ORCHESTRATOR with a real binary on main: 'move-selection 2 0 . hide-selected-row-item' now writes hidden: Region/East while a bare hide writes Region/North. THREE INSTANCES OF THE TICKET'S OWN LESSON, all found during the fix, none predicted: (1) the TUI's ':' line had the IDENTICAL defect -- ExecuteCommand also used parse_line_with, so :move-selection 2 0 reset the cursor; (2) register_pure's two arms DISAGREED, its interactive arm refusing empty args while its pure arm accepted them, so 'w' parsed one way and refused the other; (3) found by ChatGPT Codex -- clear-cell, yank, paste and drill-into-cell had interactive arms taking |_args, ctx| that DISCARDED explicit coordinates, harmless only while scripts reached the pure arm, so after the fix 'clear-cell Region/East' cleared the origin. Here the WEAKER arm was the one that HAD the context. Resolved by key_from_args_or_cursor: explicit coordinates win, cursor is the fallback.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xgra","title":"pr-reviews is blind to inline review comments","description":"scripts/pr-reviews printed review BODIES only. On PR #122 the user reviewed with the body 'Generally good, added comments inline.' and five substantive rulings as inline comments; the script printed the one-line body and nothing else, and an agent reported the PR as awaiting review. Extend it to all three surfaces (reviews, pulls/{n}/comments, issues/{n}/comments) interleaved in time order, and give it a fixture-driven check_the_checker.","notes":"FIXED in PR #133 (merge 22ab58f). pr-reviews now prints all three surfaces --\nreview bodies, INLINE comments, issue comments -- interleaved in time order, with\na fixture-driven check_the_checker (22 cases) in CI's kb job.\n\nTHE DAMAGE IT REPAIRS, for the record: on PR #122 the user reviewed with the body\n\"Generally good, added comments inline.\" and five substantive rulings underneath.\npr-reviews printed the one-liner, and the orchestrator reported the PR as awaiting\nreview while the answers had been sitting there. The user had to say so.\n\nTHE FINDING THAT IS SHARPER THAN THE BUG -- VERIFIED BY THE ORCHESTRATOR AGAINST\nTHE LIVE API: commit_id is PER-COMMENT, not per-review, and they differ INSIDE ONE\nSUBMISSION. On #122, one review submitted 19:48-19:53 carries five inline\ncomments: three naming 246e8f4a and two naming 6a4c7dcd, while its body names\n246e8f4a. GitHub pins each comment to the file version it was written against, so\na comment on an outdated diff position keeps the older commit while its siblings\nmove on.\n\nSo the old head check was answering about the WRONG OBJECT, not merely reading too\nfew of them. A head comparison against the review BODY is not even a proxy for its\nown comments.\n\n--wait: AN INLINE COMMENT AT THE HEAD SATISFIES IT, argued rather than assumed --\na pulls/{n}/comments entry carries a commit_id, so it is a claim about which tree\nwas read. The clincher is that GitHub's \"Add single comment\" CREATES A REVIEW\nWHOSE BODY IS EMPTY: under the old rule that reviewer's exit code was right while\ntheir words were invisible, so the surfaces and the wait had to move together or\nthe tool would be honest and useless. AN ISSUE-LEVEL COMMENT DOES NOT satisfy it\n-- no commit, so no claim about a tree; \"I am rebasing shortly\" satisfying a wait\nfor a review is the widening that looks like the fix, and there is a fixture and\nan injected fault for exactly it.\n\nSIX FAULTS INJECTED, each caught by the case that names it. The two found by Codex\non the PR are the interesting ones and are bash-5-only:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5: every\n name is created before the assignments run, so $n is unbound under set -u.\n macOS ships bash 3.2, where it works. THE SUITE PASSED 19/19 LOCALLY AND 2/19\n IN CI ON THE SAME COMMIT -- which is the argument for wiring a shell checker\n into CI in the SAME change that adds it.\n - gh api --paginate emits one array PER PAGE, so a long thread is several\n concatenated documents.\n\nAND THE DIAGNOSTIC WORTH CARRYING: both failed as EXIT 2 WITH NO OUTPUT AT ALL,\nbecause a dead command substitution hands jq --argjson an empty string and jq\nanswers an invalid --argjson with a USAGE error, which is 2. A shell fault at the\ntop of a script arrives wearing jq's exit code at the bottom.\n\nNOT VERIFIED: the LIVE --paginate path. No PR in this repo has enough inline\ncomments to reach a second page (#122 has five), so pagination is exercised by\nfixture only -- and the fixture runs through the SAME pages_to_array, so it can\nhold the concatenated shape the live fetch actually produces.\n\nTHE AGENT ALSO WALKED INTO THIS PR'S OWN TRAP WHILE BUILDING IT: it ran\n`./scripts/pr-watch 133 \u003e f 2\u003e\u00261; echo \"EXIT=$?\"` and its tooling reported exit 0\nover a FAILING kb job, because the echo took the status. pr-watch had correctly\nexited 1. That is the strongest argument for the widened header wording being in\nthe file rather than in anyone's memory.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:44:08Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","closed_at":"2026-09-22T21:04:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e0ex","title":"A ratio renders as an integer: number_format is per view, which is too coarse for one grid","description":"USER 2026-09-22: \"Right now, a Formula like `Margin = Profit / Revenue` displays\nuseless numbers. Probably, in most cases 2 decimal places displayed is sufficient\"\n\nTHE IMMEDIATE CAUSE IS THE FORMAT, NOT THE ARITHMETIC, and that is worth stating\nplainly because the user's own diagnosis pointed at decimal math (filed separately\nas the numeric-tower ticket, which is legitimate on its own merits but is NOT what\nproduces this symptom).\n\nView::default() sets number_format to \",.0\" (improvise-core/src/view/types.rs:56),\nand web/demo.improv sets \",.0\" explicitly on both views. `.0` is ZERO DECIMAL\nPLACES. So Margin = Profit / Revenue, a ratio around 0.66, is rounded\nhalf-away-from-zero (gotcha 8) and drawn as \"1\". Exact decimal arithmetic would\nnot change that by one character: 0.66 formatted with \",.0\" is still \"1\".\n\nTHE REAL DEFECT IS THAT number_format IS PER VIEW, WHICH IS TOO COARSE. One\nformat cannot serve Revenue (18,400 -- wants \",.0\") and Margin (0.66 -- wants\n\".2\" or a percent) in the same grid. Blanket-changing the default to 2 decimals\njust moves the damage: every currency measure becomes \"18,400.00\". So the fix is\nNOT a new default; it is a format that can vary per measure.\n\nWHAT TO DECIDE, in the PR body, rather than assume:\n - PER WHAT? Per _Measure item is the obvious unit and matches how a user\n thinks (\"Margin is a percentage\"). Per formula is another candidate and is\n narrower -- note a formula TARGET is a _Measure item (gotcha 7), so the two\n are not independent and picking both would be two answers to one question.\n - WHERE DOES IT LIVE? A per-measure format is document data, so it persists --\n which means the .improv grammar gains something and persistence/improv.pest\n is the single source of truth (design-principles section 3). Grammar first,\n then the tree-walker.\n - WHAT IS THE FALLBACK? The view's number_format must remain the answer for a\n measure with no format of its own, or every existing document changes\n appearance on load.\n - IS A SENSIBLE DEFAULT INFERRABLE? A formula whose expression is a division\n is almost always a ratio. Inferring is tempting and is the kind of rule that\n is wrong 10% of the time and unexplainable when it is -- if you propose it,\n propose it as a DEFAULT THE USER CAN OVERRIDE and say how they override it.\n\nROUND-TRIP IS THE TEST THAT MATTERS: persistence/roundtrip_tests.rs and the\ngrammar-walking generator. A new format field that does not survive\nformat_md -\u003e parse_md is a silent data loss, and the generator is what will find\nthe spellings you did not think of.\n\nNOTE FOR SCOPE: this is display only. format_f64 is called in rendering and\nnowhere else; formula eval uses full f64 and must keep doing so (gotcha 8 -- never\nfeed a rounded value back into eval). Nothing here touches the evaluator.","notes":"USER RULING 2026-09-22, which NARROWS this ticket from what it was filed as:\n\"We need to default to showing decimals: the expectation is that 2/3 shows\nsomething like .66... rather than 1 for this sort of application, unless the\nformat is explicit in the formula\"\n\nTWO CLAUSES, and the second changes the design:\n\n1. THE DEFAULT SHOWS DECIMALS. View::default()'s \",.0\" becomes decimal-showing.\n Combined with the user's earlier \"in most cases 2 decimal places displayed is\n sufficient\", that reads as \",.2\": Revenue draws 18,400.00 and Margin draws\n 0.66. Accounting applications do exactly this, so the noise on whole-number\n measures is the expected cost rather than a regression.\n\n2. THE OVERRIDE IS SPELLED IN THE FORMULA. This SUPERSEDES the \"per-_Measure\n item\" framing this ticket was filed with. Per-formula is narrower and is what\n the user asked for -- and the two nearly coincide, because a formula TARGET is\n a _Measure item (gotcha 7). So: a formula may carry its own format, which wins\n for its target; a RAW measure keeps the view's format. Clean split, and the\n grammar change lands in the `## Formulas` line rather than in a new section.\n\nTHE MIGRATION IS CLEANER THAN EXPECTED -- verified, not assumed. format_md writes\n`format: \u003cfmt\u003e` whenever number_format is non-empty (persistence/format.rs:121),\nand View::default() is non-empty, so EVERY DOCUMENT IMPROVISE HAS EVER WRITTEN\ncarries an explicit format line. format_line is optional in the grammar\n(improv.pest:118/126), so changing the default affects only (a) newly created\nviews and (b) hand-written documents that omit the line. No migration is needed\nfor round-tripped files.\n\nBUT web/demo.improv SETS \",.0\" EXPLICITLY ON BOTH VIEWS (lines 11 and 19), so the\ndefault change alone leaves the front page showing the bug the user reported.\nChange the demo in the same commit, and note tests/web_demo_model.rs asserts\nproperties of that document.\n\nGRAMMAR NOTES for the formula-side format:\n - The `## Formulas` line already carries a bracket suffix for the target\n category: `- Tax = Revenue * 0.1 [CustomCat]`. A format needs a spelling that\n cannot be confused with a category name. Decide it in improv.pest FIRST --\n the grammar is the single source of truth and the tree-walker follows\n (design-principles section 3).\n - The grammar-walking generator reads improv.pest via pest_meta and will\n exercise whatever you write, including spellings you did not intend. Note it\n SKIPS PREDICATES (gotcha 34), so a rule guarded only by a lookahead is a rule\n the generator can violate -- use a real token, as WHERE's `sp+` does.\n - Formula::raw is what the writer emits, not the reconstructed text, so a\n format that round-trips through `raw` may appear to work while being dropped\n from the parsed structure. gotcha 34 records exactly that failure for the\n formula TARGET: the round-trip tests never saw it because raw was unaffected.\n Assert the PARSED format, not the re-emitted line.\n\nSTILL DISPLAY ONLY. format_f64 is called in rendering and nowhere else; eval uses\nfull f64 and must keep doing so (gotcha 8). ADR 005 (improvise-l2b8) is the\nseparate question of what the arithmetic itself is, and nothing here waits on it.\nUSER CONSTRAINT 2026-09-22: \"improvise-e0ex should not affect persistence at all,\nexcept to store format metadata.\"\n\nALREADY TRUE BY CONSTRUCTION -- verified before recording, so the job is to NOT\nBREAK IT rather than to build it:\n\n - DISPLAY formatting is format_f64 in improvise-core/src/format.rs. Its only\n callers are rendering paths.\n - PERSISTENCE formatting is a DIFFERENT function in a DIFFERENT crate:\n persistence/format.rs:38 `fn format_number(n: f64)`. It writes `{n}` --\n Rust's shortest round-trip representation -- for non-integers, `{n as i64}`\n for integers below 1e15, and named forms for inf/-inf/nan.\n - `grep format_f64` over crates/improvise-io, crates/improvise-core/src/*.rs\n and src/*.rs returns ONLY format.rs's own definition and its tests.\n PERSISTENCE NEVER CALLS THE DISPLAY FORMATTER.\n\nSo stored values keep full round-trip precision regardless of what any view's\nnumber_format says, and a ,.2 default cannot round anything on disk.\n\nTHE REGRESSION TO WATCH FOR, stated because it is the one way this constraint\ngets violated by someone trying to be helpful: routing a save through the display\nformatter \"so the file matches what the user sees\". That would make the on-disk\nvalue depend on a VIEW's setting -- and a model has many views (gotcha 29), so\nthere is no single right answer and the last save would win. gotcha 8 already\nforbids feeding a rounded value back into eval; this is the persistence twin of\nit and is now written down.\n\nWHAT IS IN SCOPE ON THE PERSISTENCE SIDE, and it is only metadata:\n 1. The per-formula format spelling in the `## Formulas` line -- grammar first\n in improv.pest, then the tree-walker.\n 2. The `format:` line's VALUE for newly created views changes with the default\n (\",.0\" -\u003e the decimal default). That is metadata, not data, and existing\n documents are unaffected because format_md has always written the line\n explicitly (format.rs:121) whenever number_format is non-empty.\n\nNOTHING ELSE IN .improv CHANGES. No data line is rewritten, no number is\nre-rendered, no migration is needed.\nFIXED in PR #125 (merge 5fdce21). 1654 tests, all gates green.\n\nMEASURED, not asserted -- web/demo.improv through project::grid_of, the path both\nfront ends use:\n Revenue Cost Profit Margin\n before 18,400 6,200 12,200 1\n after 18,400.00 6,200.00 12,200.00 0.66\n with FORMAT .4 on a ,.0 view 0.6630\n\nONE CORRECTION TO THIS TICKET AND TO THE BRIEF I WROTE FROM IT: the demo has NO\nMargin formula -- only `Profit = Revenue - Cost`. Verified on main. So the front\npage was never literally showing the reported symptom; it was showing ,.0 on\nevery measure. My brief asserted the demo would \"still show the bug\" after a\ndefault change, which was wrong because I assumed a ratio that is not there.\nThe agent declined to ADD a Margin formula: that is demo content nobody asked for\nand it moves tests/web_demo_model.rs's asserted column list plus harness\nassertions. It is a two-line change if the user wants the ratio on the front page.\n\nTHE GRAMMAR DESIGN IS BETTER THAN THE BRIEF ASKED FOR. improv.pest is UNCHANGED\n-- verified. formula_raw already takes the `## Formulas` line verbatim up to the\noptional [Category] suffix, so the format rides in the FORMULA TEXT:\n format_clause = ${ ^\"FORMAT\" ~ sp+ ~ format_spec }\nin formula.pest, after the optional WHERE, with sp+ rather than a lookahead for\nWHERE's reason (the grammar-walking generator skips predicates). Consequences:\nthe generator has no new spelling to exercise, and the text a user types into the\nTUI minibuffer is the text the file carries -- one namespace, one spelling rule.\nRejected: a `{.2}` line suffix, which puts the format somewhere a user cannot\ntype it.\n\nTHE raw-VS-PARSED TRAP WAS ANSWERED WITH A MEASUREMENT. Injecting a walker that\nDISCARDS the format clause fails exactly ONE test and passes 242 others in\nimprovise-io, including the whole format_md/parse_md characterization suite and\nthe grammar-walking properties. No text-comparing test can see the field go\nmissing -- which is the gotcha-34 shape this ticket warned about, now quantified.\n\nBOTH CODEX FINDINGS WERE REAL, and both are the kind that only review catches:\n 1. FormatPlan used a HashMap keyed by target. A target category need not be\n _Measure, so a key can be claimed twice and HASH ORDER DECIDED THE SPELLING\n while model order decided the value -- gotcha 18's shape in a new place.\n Now: every formula's (cat, target, Option\u003cformat\u003e) in MODEL order, FIRST\n match wins, and a match naming no format falls through to the view.\n 2. A column total took its format from ROW 0. The agent's own context/ prose\n had argued this was safe \"for the case it cannot arise in\" -- and it arises,\n because a formula's target category is any category and can own a ROW-AXIS\n item. New GridLayout::column_key(col) gives the coordinates surviving the\n row aggregation.\n\nTWO FIXTURES PINNED TO ,.0 RATHER THAN RE-BASELINED, and the first is the\nimportant one: improvise_gp::dataset::a_cell_reaches_the_table_unrounded asserted\nits fixture's format WAS ,.0 as a precondition, then checked a stored 5.25\nreaches a fit unrounded. UNDER A ,.2 DEFAULT THAT TEST WOULD HAVE GONE VACUOUS\nWHILE PASSING. The fixture now sets ,.0 explicitly.\n\nNO .improv DATA LINE CHANGED. The user's constraint held.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:16:26Z","closed_at":"2026-09-22T07:16:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y11g","title":"The : command line must be present in the browser (core feature): a request frame carrying the line","description":"USER RULING 2026-09-22: \"The : command line needs to be present in the browser,\nit's another core feature.\"\n\nThis REVERSES the decline. resolve.rs::stranding() currently refuses\n`enter-mode command-mode` (with SavePrompt and ExportPrompt), and the\nimprovise-h3y7 audit classified it \"host-only unless the ruling is otherwise\n-- shipping it to a browser means shipping the registry\". The ruling is\notherwise, AND the audit's framing of the cost was wrong.\n\nTHE COST IS NOT WHAT IT LOOKED LIKE. The registry does not have to reach the\nclient, because IN BOTH DEPLOYMENTS THE HOST ALREADY HAS ONE:\n\n - socket (?ws=): the host is native improvise-ws-server. Obviously has it.\n - worker (no ?ws=): web/build.sh builds TWO wasm bundles -- web/pkg from\n improvise-wasm-client (thin) and web/worker-pkg from improvise-worker-server,\n which depends on improvise-command and therefore ALREADY CONTAINS THE FULL\n CmdRegistry, in the browser, today.\n\nSo a `:` line is a REQUEST FRAME CARRYING A STRING, executed by the host against\nits own App and CmdContext. Zero new bundle weight on the main thread, and no\nsecond implementation of the command language.\n\nTHIS MATTERS BECAUSE THE ALTERNATIVE IS EXPLICITLY FORBIDDEN. improvise-wasm-client's\nCargo.toml carries a written argument for why improvise-command is deliberately NOT\na dependency: a binding names an intent resolved against a CmdContext -- a Model, a\nGridLayout and an App the client does not have -- so depending on it would drag the\nwhole host into the bundle to reach a lookup that could not be used. Do not do that.\n\nSHAPE, and note it is the SAME shape as the export/download ticket (improvise-2mqz),\nwhich is a reason to land them together or at least to design them once:\n ClientFrame -\u003e the line, as a string\n ServerFrame -\u003e Rejected on a parse or execution failure, else the ordinary\n projection stream carries whatever the command changed\n\nWHY A FRAME AND NOT A Command, for improvise-368h's reason: ExecuteCommand runs an\narbitrary script line and is runtime-fallible, so it must not sit inside a\nClientFrame::Commands batch that promises all-or-nothing.\n\nBLOCKED ON improvise-jz6w: a resolver cannot emit a frame today.\n\nWHAT MUST LAND WITH IT, not after:\n - the minibuffer must actually RENDER. The user reported 2026-09-21 that the\n minibuffer for typing a category name \"doesn't actually show up anywhere so\n you're typing blind\". A `:` line you cannot see is not the feature.\n - remove the CommandMode arm from stranding() and say so in the named list.\n - help page 5 already advertises the whole `:` surface to browser users\n (improvise-uln5). Shipping this closes the advertised-but-unreachable gap for\n `:` specifically; check what uln5 has left afterwards rather than closing it.\n\nSECURITY NOTE, because `:` is a wider door than any other frame: a line can reach\n`:w`, `:e` and the import wizard, i.e. the host's Storage. In the worker deployment\nthat is the user's own OPFS and is fine. In the socket deployment it is SOMEONE\nELSE'S FILESYSTEM, and improvise-kc7n's Origin check answers what page is driving a\nclient, never WHO is connecting -- improvise-kpnr (authentication) is still open.\nDecide explicitly whether the socket deployment accepts `:` lines at all, or whether\nthe frame is refused there until kpnr lands. Do not let this arrive by accident.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:57Z","labels":["browser","protocol"],"dependencies":[{"issue_id":"improvise-y11g","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T05:14:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:16Z","labels":["protocol"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-fdk1","title":"A records coordinate rename has no wire form, and the surrogate is why","description":"Found by the improvise-h3y7 completeness audit, WHICH CORRECTED THE TICKET'S OWN\nDESCRIPTION OF THIS GAP -- and the correction changes the fix.\n\nimprovise-h3y7 said \"grepping reduce.rs for Rename returns zero\". True, and\nMISLEADING. Two paths do this:\n\n - effect::apply_coordinate_rename (ui/effect.rs:655), inside ApplyAndClearDrill,\n which is deliberately off the wire (gotcha 38).\n - commit.rs::rename_record_coordinate (cmd/commit.rs:366), the plain records path,\n which ALREADY decomposes into [AddItem, ClearCell(old), SetCell(new)] -- three\n commands that all have ModelCommand forms.\n\nSo it reads as though the plain path were already wire-expressible. IT IS NOT, for a\nreason the ticket does not state: ModelCommand::SetCell takes a RecordCell, whose\nRecordHash is an OPAQUE SURROGATE the server resolves against cells that ALREADY\nEXIST (App::resolve_record, ui/app.rs:516). The renamed record does not exist yet, so\na client cannot name it.\n\nTwo things that look like they solve this and do not. `will_be_created_by_the_frame`\n(ui/reduce.rs:284) exempts such a record from the BASIS CHECK -- it says nothing about\nhow to NAME one, and the two are easy to confuse. And RecordHash::of being pub is not\na licence: a client computing surrogates is precisely the invention ADR 002 exists to\nprevent.\n\nWHAT TO ADD: one position-free variant naming the existing record by surrogate and the\nnew coordinate by value --\n\n ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }\n\n-- mapped to a SINGLE effect factored out of the two copies above. Position-free, so\nimprovise-hkbc's rule still holds. Add it to records_named's match: it names one\nrecord, and creating the ITEM is internal to the effect, as both existing copies\nalready do.\n\nCONSOLIDATING THE TWO COPIES IS WORTH DOING REGARDLESS. They differ today only in how\nthey report the empty-value refusal, and gotcha 19 already warns that the synthetic-\nrecords branch and the plain branch drift.\n\nREMEMBER gotcha 1: renaming a category coordinate in wide records mode moves EVERY\nmeasure cell in the row, not just the one under the cursor. That is also why the basis\nunit is the record (gotcha 40) -- a cell-level unit is violated by this one legal edit.\n\nRELATED: improvise-o9uq is about the same code. Read it in full and say whether this\nsubsumes it before starting.","notes":"agent-wire2 2026-09-22, NOT BUILT, and here is the argument. There is no producer for the variant. A browser cannot enter records editing: edit_or_drill declines when state.grid.records is set (improvise-bd9o), toggle-records-mode and drill-into-cell are both in declined(), and a records coordinate cell projects id: None (improvise-exdg), so a client has no surrogate for it to echo. RenameRecordCoordinate would be a ModelCommand that nothing composes, which is the promise-not-kept shape vocabulary_is_composed.rs refuses for Command. Composing it needs the browser half of commit_plain_records_edit, and that needs RecordCol, which is session-local by gotcha 1. bd9o records that as a design question. So this ticket is BLOCKED on that design, not on vocabulary. What stands regardless: consolidating effect::apply_coordinate_rename with commit.rs::rename_record_coordinate (gotcha 19 drift). That work is in the command layer, outside the wire lane.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:36:47Z","labels":["protocol"],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-2mqz","title":"SaveAs and ExportCsv have no wire form, which is why two prompts are stranded","description":"Found by the improvise-h3y7 completeness audit (direction A: effects -\u003e vocabulary).\nOf 32 ViewOnly effects, these are the only two that write storage and have NO wire\nform of any kind -- exactly the hole `Save` was in before improvise-368h.\n\neffect::SaveAs(path) and effect::ExportCsv(path) are unreachable from any client.\nresolve.rs::stranding() refuses SavePrompt and ExportPrompt for precisely this\nreason, so THE DECLINE IS A SYMPTOM: closing it means closing this, not editing the\ndecline.\n\nSHAPE: request FRAMES, not Commands -- improvise-368h's stated reason applies\nunchanged. A runtime-fallible write inside a ClientFrame::Commands batch breaks the\nall-or-nothing promise; [Save, SetCell] reported a failed write and then applied the\nedit. And neither mutates shared state or any client's slice, so reduce_view has\nnothing to fold and Checked has no basis to answer for.\n\n ClientFrame::SaveAs(PathBuf) / ClientFrame::ExportCsv(PathBuf)\n\nanswered ServerFrame::Rejected(..) on failure, to the asker alone, and SILENTLY on\nsuccess -- nothing moved, so no widget is dirty.\n\nBLOCKED ON the KeyOutcome shape gap (a resolver cannot emit a frame today). Land that\nfirst.\n\nTHE OPEN QUESTION THIS RAISES AND DOES NOT ANSWER -- flag it in the PR and get a\nruling before shipping: a browser client naming a path in the HOST's Storage is a\ndifferent act from a TUI user doing it (gotcha 2 -- the host picks the backend). The\nalternative is that a browser gets a download and never names a host path. Say which\nin context/code/protocol.md; different protocols follow from each.","notes":"=== USER RULING 2026-09-22 -- THE SHAPE ABOVE IS SUPERSEDED. READ THIS FIRST. ===\n\n'Save can save to OPFS, but exports should be downloads. There should also be a\nway to download the .improv file.'\n\nSo the description's proposed ClientFrame::SaveAs(PathBuf) / ExportCsv(PathBuf) is\nWRONG and must not be built. A browser client never names a path in the host's\nStorage. That also ANSWERS the open question the description flagged, in the safe\ndirection -- do not re-open it.\n\nTHE CORRECT SHAPE: a request frame that asks the host for BYTES, and a page that\nturns bytes into a download. Uniform across both deployments, names no path, and\ncarries no filesystem semantics across the wire at all.\n\n ClientFrame -\u003e a request for the rendered document or export\n ServerFrame -\u003e the bytes (or Rejected)\n web/app.js -\u003e Blob + an \u003ca download\u003e, which is the ONLY new browser-side code\n\nTWO ARTEFACTS ARE WANTED, not one: the CSV export, and THE .improv FILE ITSELF.\nThe second is new scope the original ticket did not have and is arguably the more\nimportant of the two -- it is how a browser user gets their document out of OPFS\nand into a file they own. persistence::format_md already renders it; nothing new\nis needed server-side beyond the frame.\n\nWHY BYTES AND NOT CLIENT-SIDE RENDERING, which is the tempting shortcut:\nimprovise-io IS in the browser bundle, so the worker deployment could render CSV\nlocally. The socket deployment could not -- a thin client holds no Model. Rendering\nlocally where possible and asking otherwise would be TWO derivations of one answer,\nwhich is the disease design-principles §5 names and improvise-edp is the worked\nexample of. Ask the host in both.\n\nNOTE render_csv must build its layout with with_frozen_records, NOT GridLayout::new\n(gotcha 1, improvise-iot6) -- export.rs already does; do not regress it.\n\nSAVE ITSELF NEEDS NOTHING NEW: the worker already persists to OPFS on the dirty\nflag (gotcha 45), and ClientFrame::Save exists for the socket deployment. What is\nmissing there is only the KEY that reaches it -- improvise-jz6w.\n\nSTILL BLOCKED ON improvise-jz6w for the same reason as before: a resolver cannot\nemit a frame today.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:04Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:41Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-2mqz","depends_on_id":"improvise-jz6w","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-owep","type":"blocks","created_at":"2026-09-22T05:24:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jz6w","title":"KeyOutcome cannot express 'send a frame', so Ctrl-S cannot reach the ClientFrame::Save that already exists","description":"Found by the improvise-h3y7 completeness audit. It is a SHAPE gap, not a vocabulary\ngap, which is why an audit that enumerates Command/ModelCommand against effects finds\nnothing here.\n\nClientFrame::Save landed with improvise-368h and Host::handle answers it (ui/host.rs:83).\nBut KeyOutcome is `Local(Vec\u003cCommand\u003e) | Upstream(KeyEvent) | Ignored`\n(improvise-protocol/src/resolve.rs:20), so a resolver has NO WAY to say \"send this\nframe\". Consequently `save` is still on the_command_names_that_still_need_the_host_are_these,\nresolves to Upstream, and Host::handle answers Upstream with an empty vec (gotcha 47:\nan upstream key is a BLACK HOLE, not a slow path).\n\nSo: an implemented server feature that no key can reach.\n\nWHAT TO ADD: a fourth KeyOutcome variant carrying a ClientFrame -- or a narrower\n`Request` enum, if a resolver handing out arbitrary frames is too wide. ARGUE THE\nCHOICE in the PR rather than picking silently; the narrow form is the one that keeps\na resolver from composing a Viewport or a Commands batch by accident.\n\nThen bind Ctrl-S through it and REMOVE `save` from the host list in the same commit --\ngotcha 47: closing a gap has to come to that list and say so.\n\nTHE GUARD: end to end, in the style of tests/browser_round_trip.rs -- Ctrl-S on a\nClientState yields the save request, and Host::handle on that request writes the\ndocument through a real App. Not two half-tests that never meet.\n\nTHIS UNBLOCKS the SaveAs/ExportCsv frames (filed separately), which need the same\nmechanism. Do this one first; it is small on its own.\n\nNOTE for whoever reads KeyOutcome afterwards: context/code/clients.md should say that\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration. That omission is why this gap was invisible to the audit's first pass.","notes":"FIXED in PR #134 (merge a799e24). Reproduced at HEAD first: the new end-to-end\ntest failed with left: Key { code: Char('s'), modifiers: CONTROL } right: Save --\nCtrl-S went Upstream -\u003e ClientFrame::Key -\u003e Host::handle's empty vec.\n\nTHE WIDTH WAS THE DECISION, and it is argued rather than defaulted:\nKeyOutcome::Request(Request) with a NARROW enum (Request::Save, plus\nRequest::frame as the one mapping to an envelope), NOT a ClientFrame. Two reasons:\n 1. An arbitrary frame lets a resolver compose a Viewport, whose extent is the\n PAGE's fact and reaches no derivation from a keymap.\n 2. The serious one: it lets a resolver compose a Commands batch, which is\n INDISTINGUISHABLE ON THE WIRE FROM A Local while skipping the local fold\n Local's contract requires. That desync has nothing to observe it.\n\nTwo consequences guarded rather than asserted in prose: `requested` is checked\nFIRST in step, so no name sits in two lists; and a request cannot travel inside a\nBinding::Sequence (a sequence is one frame), with\nno_binding_puts_a_request_inside_a_sequence keeping that refusal arm unreachable\nrather than merely untested. NOT routed through Commands -- gotcha 45's reason\nholds. `save` left the host-needed list in the same commit.\n\nTHE LOAD-BEARING RIVAL: Request::Save =\u003e ClientFrame::Commands(vec![]). The\nSTORAGE assertion alone cannot separate \"reached the host\" from \"reached the host\nas the wrong kind of thing\", because an empty batch writes nothing either way.\nAsserting the FRAME is the discriminator.\n\nTWO FINDINGS WORTH MORE THAN THE FIX:\n\n1. THE AUDIT THAT FOUND THIS TICKET COULD NOT HAVE FOUND IT. improvise-h3y7\n enumerated Command/ModelCommand against the effects and nothing was missing\n from either -- the gap was in KEYOUTCOME'S SHAPE. KeyOutcome's variants are\n protocol surface too, and an enumeration that stops at the frames and the\n commands will miss the next one the same way.\n\n2. THE HOST SIDE WAS ALREADY TESTED, by a_client_can_ask_its_host_to_save, WHICH\n CONSTRUCTS THE FRAME ITSELF. A passing test over a feature nothing can reach is\n the \"green suite is not evidence\" shape exactly -- improvise-368h shipped that\n way. Worth checking whether other Host::handle arms have only\n self-constructed-frame coverage.\n\nNOTE ON repo-map: the agent measured 1686 at d672985 over 38 binaries, where\nrepo-map still says 1464/35 -- stale by 222. The File Inventory's own preamble\nsays those are a dated snapshot, so this is the preamble being right rather than\na defect.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:03Z","created_by":"Edward Langley","updated_at":"2026-09-22T20:59:52Z","closed_at":"2026-09-22T20:59:52Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"improvise-m0sx","title":"Command::ClearBuffer is invented, and disagrees with the only implementation of its own name","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects),\nand VERIFIED INDEPENDENTLY by the orchestrator before filing.\n\nTHE DIVERGENCE. reduce_view's arm (improvise-protocol/src/reduce.rs:269) does\n`view.buffers.remove(name)` -- it REMOVES the key. The host's `clear-buffer`\ncommand (command/cmd/effect_cmds.rs:226) composes `core::write_buffer(name, String::new())`,\nwhich is `effect::SetBuffer` -- and SetBuffer INSERTS, leaving `buffers[name] == \"\"`.\nresolve.rs::write_buffer composes the same SetBuffer. So the one behaviour both\nends actually implement leaves an empty string, and the wire variant says otherwise.\n\nThere is NO `ClearBuffer` effect anywhere in the workspace. The variant is therefore\nnot derived from the effect set, violating decision(improvise-cqi,\ncommand_set_derived_not_invented). It is the exact INVERSE of the ViewBack defect\nthat improvise-h3y7 was opened over: ViewBack was an effect with no Command; this is\na Command with no effect.\n\nREACHABILITY. `grep -rn \"Command::ClearBuffer\" crates/ src/ web/` returns exactly\nthree hits, ALL inside reduce.rs: the definition at :39 and two of reduce.rs's own\ntests at :405 and :465. No production code composes it. (Note `ClearBufferCmd` in\neffect_cmds.rs and registry.rs is the command-layer NAME, not the wire variant --\nthey are different things that read the same, which is part of why this survived.)\n\nImpact if it were ever sent: a Snapshot seeded from a ViewState where the host left\n`buffers[\"edit\"] == \"\"` against an incrementally-folded client with no key -- two\nclients whose ViewStates are PartialEq-unequal for the \"same\" state.\n\nDECIDE ONE, AND SAY WHICH IN context/code/protocol.md:\n(a) delete the variant and its two tests -- free today, since nothing composes it; or\n(b) keep it as the derived form and change `clear-buffer`'s effect to remove the key,\n which makes ViewState equality agree between a folded client and a snapshot-seeded one.\n\nTHEN ADD THE GUARD that makes this whole class visible: an exhaustive test over\n`variants::declared::\u003cCommand\u003e()` asserting every variant is composed by some\nproduction resolver or relay. The machinery is already in\ncrates/improvise-protocol/src/variants.rs. Prove it discriminating by deleting one\nresolver arm and watching it fail -- a variant nothing composes is either dead or a\npromise, and both need naming.\n\nNOTE the two existing tests currently PIN the divergent behaviour (they assert\n`!view.buffers.contains_key(\"edit\")`), so whichever answer is taken, they change.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-22T04:46:49Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:48:53Z","started_at":"2026-09-23T02:48:53Z","lease_expires_at":"2026-09-23T02:53:53Z","heartbeat_at":"2026-09-23T02:48:53Z","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h3y7","title":"The wire vocabulary must be complete against what needs the server, not merely correct","description":"USER RULING 2026-09-21: \"the wire protocol needs to cover the full range of commands that need the server side.\"\n\nThis is a COMPLETENESS requirement on the vocabulary, and it lands on a\nweakness the wave just demonstrated rather than on a suspicion.\n\nTHE PRECEDENT THAT MAKES IT URGENT. PR #115 found that gotcha 38's account of\nwhy ViewBack/ViewForward were off the wire was FALSE: it said they were\nsession-local residue like ApplyAndClearDrill, but view_back_stack is in\nViewState -- the portable slice -- so by the view half's own rule\n(decision(improvise-cqi): the ViewOnly effects targeting the portable slice)\nthey were always Commands and were simply MISSING. The derivation had been\nchecked for CORRECTNESS -- every listed variant belongs -- and never for\nCOMPLETENESS. The cost was a live divergence nobody could see: reduce_view's\nSwitchView arm set active_view and nothing else while effect::SwitchView pushes\nthe back stack, so NO CLIENT EVER HAD A BACK HISTORY.\n\nSo this bead is: enumerate the vocabulary AGAINST the rule, in both directions,\nand close what the enumeration finds.\n\nTHE KNOWN GAPS AT 523d571, to be treated as a starting list rather than the\nanswer:\n\n1. NO ModelCommand FOR A COORDINATE RENAME. ApplyAndClearDrill's\n RecordCol::Category(_) arm is apply_coordinate_rename -- add an item, then\n move EVERY measure cell in the row (gotcha 1) -- and grepping reduce.rs for\n Rename returns zero. A drill's category-column commit therefore has no wire\n form. This is a missing ModelCommand, NOT a missing addressed form, and is\n much smaller than it was assumed to be.\n\n2. SIX NAMES IN declined() (resolve.rs:232) that a browser cannot ask for at all:\n drill-into-cell, toggle-prune-empty, toggle-records-mode,\n add-record-row, delete-formula-at-cursor, view-back\n plus enter-mode via stranding (SavePrompt, ExportPrompt, CommandMode).\n view-back becomes removable when PR #115 merges -- the comment blaming the\n drill commit is wrong as of that PR. The others each need their own answer:\n some want a ModelCommand, some want a projection, and some may be genuinely\n host-only. SAY WHICH, PER NAME.\n\n3. EIGHT NAMES in the_command_names_that_still_need_the_host_are_these:\n hide-selected-row-item, toggle-group-under-cursor, search,\n search-navigate, search-or-category-add, enter-advance, save, force-quit\n (search-or-category-add leaves with improvise-koh6/PR #114.) Note gotcha 47:\n an UPSTREAM key is a black hole, not a slow path -- Host::handle answers\n ClientFrame::Key with an explicit empty vec -- so every name on this list\n does NOTHING in a browser rather than being merely slower.\n\n4. ClientFrame::Key ITSELF. It exists, is deployment-independent by gotcha 42's\n argument, and is answered with an empty vec. Either it should carry\n something or it should not exist; today it is a promise the protocol does\n not keep.\n\nWHAT THIS BEAD IS NOT: a licence to put everything on the wire. gotcha 38\nrecords two effects deliberately EXCLUDED -- ApplyAndClearDrill and\nAcceptWizardModel -- because their operands are session-local residue addressed\nto precisely one session, and an unaddressed form lets client B commit client\nA's typing (demonstrated against Sessions::apply before removal). The rule is\n\"everything that NEEDS the server\", and residue does not. The test for each\ncandidate is the one gotcha 38 already states: does the argument for excluding\nit name the thing it differs in?\n\nACCEPTANCE: a per-name verdict for every entry in (2) and (3) -- on the wire,\ndeliberately excluded with the argument, or blocked on a named prerequisite --\nplus the enumeration in (1) closed or filed. A list of what is missing is worth\nmore than a partial fix, and doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)\nmeans the verdict list has to be maintained where it can be checked, not left\nin this bead.\n","notes":"AUDIT COMPLETE 2026-09-22 against 44b8819. Both derivations run.\n\nMECHANISM: brace-matched impl Effect blocks in ui/effect.rs =\u003e 26 EffectKind::Model(_),\nverified against gotcha 38's stated 26/24. NOTE THE MECHANISM UNDER-REPORTS BY 9: it\nmisses the 8 simple_effect! macro expansions and the one impl Effect in\ncommand/keymap.rs (SetTransientPrefix). Total effects = 58: 26 Model(_), 32 ViewOnly.\nAll 9 missed are ViewOnly, which is exactly the half the ViewBack bug lived in.\nGotcha 38 warns the naive awk OVER-reports by picking up HelpPageSet; the brace-matcher\nit recommends instead UNDER-reports, and nothing in context/ said so.\n\nDIRECTION A (effects -\u003e vocabulary): no gap in the model half -- all 26 accounted for.\nIn the view half, 21 of 32 have a Command; 9 of the remaining 11 are soundly excluded\nand TWO ARE A GAP: SaveAs and ExportCsv write storage and have no wire form at all\n(=\u003e improvise-2mqz).\n\nDIRECTION B (vocabulary -\u003e effects), NEVER RUN BEFORE, three finds:\n - Command::ClearBuffer is INVENTED and contradicts the only implementation of its\n name (=\u003e improvise-m0sx, P1, orchestrator-verified independently)\n - effect::HelpPageSet unclamped where its mirror clamps (=\u003e improvise-4n4i)\n - effect::SwitchView pushes the back stack before a guarded switch (=\u003e improvise-swzk)\n\nSTRUCTURAL PROPERTY FOUND AND NOT PREVIOUSLY WRITTEN DOWN: apply_command handles only\nSwitchView and Model; every other Command falls to _ =\u003e Dirty::NOTHING and is folded\ninto the asking session's mirror by reduce_view -- THE SAME FUNCTION both ends run. So\nmirror-vs-client drift is structurally impossible, and the whole direction-B risk\ncollapses to ~21 pairs of 'does reduce_view's arm do what its effect does'. That is an\nafternoon, and re-running it after any wave touching reduce_view or effect.rs is the\nonly thing standing between this repo and another ViewBack.\n\nTHE HEADLINE, which corrects this ticket's own framing: THE VOCABULARY IS FAR MORE\nCOMPLETE THAN ASSUMED. Of 13 gaps examined, EIGHT are closed in the vocabulary and open\nonly in the RESOLVER. Taken literally, this ticket would have produced new variants for\nthings already on the wire. Only three things need new wire surface: SaveAs/ExportCsv\n(frames), the coordinate rename (a ModelCommand), add-record-row (a new naming mode).\n\nGENERALISABLE FINDING: declined() is NOT evidence of a vocabulary gap and is NOT\nmaintained against the vocabulary. It is a sentence written when a name was hard, and\nnothing re-checks it when the thing that made it hard lands. view-back is the proven\ninstance (improvise-cfly is cleaning up after PR #115); toggle-prune-empty and\ntoggle-records-mode are two more sitting in that state now. The structural fix is that\nthe decline list and the host list must be DERIVED FROM or CHECKED AGAINST the\nvocabulary, not hand-kept beside it.\n\nALSO: a SHAPE gap can hide behind a vocabulary audit. 'save' looks like a missing\nCommand and is not -- ClientFrame::Save exists and is answered; the only missing thing\nis a KeyOutcome variant that can express sending a frame (=\u003e improvise-jz6w).\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration; context/code/clients.md should say so.\n\nAND: the surrogate is the binding constraint on the whole CREATION class. RecordHash is\nresolved server-side against cells that exist, so a client cannot name a record it is\nabout to create. Invisible from the ModelCommand list. will_be_created_by_the_frame\nexempts such a record from the BASIS CHECK and says nothing about NAMING one -- the two\nare easy to confuse. Deserves a line in ADR 002.\n\nCHILDREN FILED: improvise-m0sx (P1), improvise-jz6w (P1), improvise-2mqz (P1, blocked\nby jz6w), improvise-fdk1 (P1), improvise-fo0w (P2), improvise-1ohv (P2, blocked by\nfdk1), improvise-4n4i (P3), improvise-swzk (P3).\nALREADY FILED, not duplicated: improvise-7xyx (raised P3-\u003eP2: it is a black-hole key,\nnot just a dead field), improvise-hs0u (prefixes), improvise-gd28 (drill),\nimprovise-bd9o (records mode), improvise-uln5 (advertised-vs-resolvable).\nFOUR PRODUCT RULINGS OWED -- see the orchestrator's hand-off.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:48:13Z","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m0sx","title":"Command::ClearBuffer is invented, and disagrees with the only implementation of its own name","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects),\nand VERIFIED INDEPENDENTLY by the orchestrator before filing.\n\nTHE DIVERGENCE. reduce_view's arm (improvise-protocol/src/reduce.rs:269) does\n`view.buffers.remove(name)` -- it REMOVES the key. The host's `clear-buffer`\ncommand (command/cmd/effect_cmds.rs:226) composes `core::write_buffer(name, String::new())`,\nwhich is `effect::SetBuffer` -- and SetBuffer INSERTS, leaving `buffers[name] == \"\"`.\nresolve.rs::write_buffer composes the same SetBuffer. So the one behaviour both\nends actually implement leaves an empty string, and the wire variant says otherwise.\n\nThere is NO `ClearBuffer` effect anywhere in the workspace. The variant is therefore\nnot derived from the effect set, violating decision(improvise-cqi,\ncommand_set_derived_not_invented). It is the exact INVERSE of the ViewBack defect\nthat improvise-h3y7 was opened over: ViewBack was an effect with no Command; this is\na Command with no effect.\n\nREACHABILITY. `grep -rn \"Command::ClearBuffer\" crates/ src/ web/` returns exactly\nthree hits, ALL inside reduce.rs: the definition at :39 and two of reduce.rs's own\ntests at :405 and :465. No production code composes it. (Note `ClearBufferCmd` in\neffect_cmds.rs and registry.rs is the command-layer NAME, not the wire variant --\nthey are different things that read the same, which is part of why this survived.)\n\nImpact if it were ever sent: a Snapshot seeded from a ViewState where the host left\n`buffers[\"edit\"] == \"\"` against an incrementally-folded client with no key -- two\nclients whose ViewStates are PartialEq-unequal for the \"same\" state.\n\nDECIDE ONE, AND SAY WHICH IN context/code/protocol.md:\n(a) delete the variant and its two tests -- free today, since nothing composes it; or\n(b) keep it as the derived form and change `clear-buffer`'s effect to remove the key,\n which makes ViewState equality agree between a folded client and a snapshot-seeded one.\n\nTHEN ADD THE GUARD that makes this whole class visible: an exhaustive test over\n`variants::declared::\u003cCommand\u003e()` asserting every variant is composed by some\nproduction resolver or relay. The machinery is already in\ncrates/improvise-protocol/src/variants.rs. Prove it discriminating by deleting one\nresolver arm and watching it fail -- a variant nothing composes is either dead or a\npromise, and both need naming.\n\nNOTE the two existing tests currently PIN the divergent behaviour (they assert\n`!view.buffers.contains_key(\"edit\")`), so whichever answer is taken, they change.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-22T04:46:49Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:39Z","started_at":"2026-09-23T02:48:53Z","closed_at":"2026-09-23T03:29:39Z","close_reason":"Merged in PR #160. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h3y7","title":"The wire vocabulary must be complete against what needs the server, not merely correct","description":"USER RULING 2026-09-21: \"the wire protocol needs to cover the full range of commands that need the server side.\"\n\nThis is a COMPLETENESS requirement on the vocabulary, and it lands on a\nweakness the wave just demonstrated rather than on a suspicion.\n\nTHE PRECEDENT THAT MAKES IT URGENT. PR #115 found that gotcha 38's account of\nwhy ViewBack/ViewForward were off the wire was FALSE: it said they were\nsession-local residue like ApplyAndClearDrill, but view_back_stack is in\nViewState -- the portable slice -- so by the view half's own rule\n(decision(improvise-cqi): the ViewOnly effects targeting the portable slice)\nthey were always Commands and were simply MISSING. The derivation had been\nchecked for CORRECTNESS -- every listed variant belongs -- and never for\nCOMPLETENESS. The cost was a live divergence nobody could see: reduce_view's\nSwitchView arm set active_view and nothing else while effect::SwitchView pushes\nthe back stack, so NO CLIENT EVER HAD A BACK HISTORY.\n\nSo this bead is: enumerate the vocabulary AGAINST the rule, in both directions,\nand close what the enumeration finds.\n\nTHE KNOWN GAPS AT 523d571, to be treated as a starting list rather than the\nanswer:\n\n1. NO ModelCommand FOR A COORDINATE RENAME. ApplyAndClearDrill's\n RecordCol::Category(_) arm is apply_coordinate_rename -- add an item, then\n move EVERY measure cell in the row (gotcha 1) -- and grepping reduce.rs for\n Rename returns zero. A drill's category-column commit therefore has no wire\n form. This is a missing ModelCommand, NOT a missing addressed form, and is\n much smaller than it was assumed to be.\n\n2. SIX NAMES IN declined() (resolve.rs:232) that a browser cannot ask for at all:\n drill-into-cell, toggle-prune-empty, toggle-records-mode,\n add-record-row, delete-formula-at-cursor, view-back\n plus enter-mode via stranding (SavePrompt, ExportPrompt, CommandMode).\n view-back becomes removable when PR #115 merges -- the comment blaming the\n drill commit is wrong as of that PR. The others each need their own answer:\n some want a ModelCommand, some want a projection, and some may be genuinely\n host-only. SAY WHICH, PER NAME.\n\n3. EIGHT NAMES in the_command_names_that_still_need_the_host_are_these:\n hide-selected-row-item, toggle-group-under-cursor, search,\n search-navigate, search-or-category-add, enter-advance, save, force-quit\n (search-or-category-add leaves with improvise-koh6/PR #114.) Note gotcha 47:\n an UPSTREAM key is a black hole, not a slow path -- Host::handle answers\n ClientFrame::Key with an explicit empty vec -- so every name on this list\n does NOTHING in a browser rather than being merely slower.\n\n4. ClientFrame::Key ITSELF. It exists, is deployment-independent by gotcha 42's\n argument, and is answered with an empty vec. Either it should carry\n something or it should not exist; today it is a promise the protocol does\n not keep.\n\nWHAT THIS BEAD IS NOT: a licence to put everything on the wire. gotcha 38\nrecords two effects deliberately EXCLUDED -- ApplyAndClearDrill and\nAcceptWizardModel -- because their operands are session-local residue addressed\nto precisely one session, and an unaddressed form lets client B commit client\nA's typing (demonstrated against Sessions::apply before removal). The rule is\n\"everything that NEEDS the server\", and residue does not. The test for each\ncandidate is the one gotcha 38 already states: does the argument for excluding\nit name the thing it differs in?\n\nACCEPTANCE: a per-name verdict for every entry in (2) and (3) -- on the wire,\ndeliberately excluded with the argument, or blocked on a named prerequisite --\nplus the enumeration in (1) closed or filed. A list of what is missing is worth\nmore than a partial fix, and doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)\nmeans the verdict list has to be maintained where it can be checked, not left\nin this bead.\n","notes":"AUDIT COMPLETE 2026-09-22 against 44b8819. Both derivations run.\n\nMECHANISM: brace-matched impl Effect blocks in ui/effect.rs =\u003e 26 EffectKind::Model(_),\nverified against gotcha 38's stated 26/24. NOTE THE MECHANISM UNDER-REPORTS BY 9: it\nmisses the 8 simple_effect! macro expansions and the one impl Effect in\ncommand/keymap.rs (SetTransientPrefix). Total effects = 58: 26 Model(_), 32 ViewOnly.\nAll 9 missed are ViewOnly, which is exactly the half the ViewBack bug lived in.\nGotcha 38 warns the naive awk OVER-reports by picking up HelpPageSet; the brace-matcher\nit recommends instead UNDER-reports, and nothing in context/ said so.\n\nDIRECTION A (effects -\u003e vocabulary): no gap in the model half -- all 26 accounted for.\nIn the view half, 21 of 32 have a Command; 9 of the remaining 11 are soundly excluded\nand TWO ARE A GAP: SaveAs and ExportCsv write storage and have no wire form at all\n(=\u003e improvise-2mqz).\n\nDIRECTION B (vocabulary -\u003e effects), NEVER RUN BEFORE, three finds:\n - Command::ClearBuffer is INVENTED and contradicts the only implementation of its\n name (=\u003e improvise-m0sx, P1, orchestrator-verified independently)\n - effect::HelpPageSet unclamped where its mirror clamps (=\u003e improvise-4n4i)\n - effect::SwitchView pushes the back stack before a guarded switch (=\u003e improvise-swzk)\n\nSTRUCTURAL PROPERTY FOUND AND NOT PREVIOUSLY WRITTEN DOWN: apply_command handles only\nSwitchView and Model; every other Command falls to _ =\u003e Dirty::NOTHING and is folded\ninto the asking session's mirror by reduce_view -- THE SAME FUNCTION both ends run. So\nmirror-vs-client drift is structurally impossible, and the whole direction-B risk\ncollapses to ~21 pairs of 'does reduce_view's arm do what its effect does'. That is an\nafternoon, and re-running it after any wave touching reduce_view or effect.rs is the\nonly thing standing between this repo and another ViewBack.\n\nTHE HEADLINE, which corrects this ticket's own framing: THE VOCABULARY IS FAR MORE\nCOMPLETE THAN ASSUMED. Of 13 gaps examined, EIGHT are closed in the vocabulary and open\nonly in the RESOLVER. Taken literally, this ticket would have produced new variants for\nthings already on the wire. Only three things need new wire surface: SaveAs/ExportCsv\n(frames), the coordinate rename (a ModelCommand), add-record-row (a new naming mode).\n\nGENERALISABLE FINDING: declined() is NOT evidence of a vocabulary gap and is NOT\nmaintained against the vocabulary. It is a sentence written when a name was hard, and\nnothing re-checks it when the thing that made it hard lands. view-back is the proven\ninstance (improvise-cfly is cleaning up after PR #115); toggle-prune-empty and\ntoggle-records-mode are two more sitting in that state now. The structural fix is that\nthe decline list and the host list must be DERIVED FROM or CHECKED AGAINST the\nvocabulary, not hand-kept beside it.\n\nALSO: a SHAPE gap can hide behind a vocabulary audit. 'save' looks like a missing\nCommand and is not -- ClientFrame::Save exists and is answered; the only missing thing\nis a KeyOutcome variant that can express sending a frame (=\u003e improvise-jz6w).\nKeyOutcome's variants are part of the protocol surface and belong in any completeness\nenumeration; context/code/clients.md should say so.\n\nAND: the surrogate is the binding constraint on the whole CREATION class. RecordHash is\nresolved server-side against cells that exist, so a client cannot name a record it is\nabout to create. Invisible from the ModelCommand list. will_be_created_by_the_frame\nexempts such a record from the BASIS CHECK and says nothing about NAMING one -- the two\nare easy to confuse. Deserves a line in ADR 002.\n\nCHILDREN FILED: improvise-m0sx (P1), improvise-jz6w (P1), improvise-2mqz (P1, blocked\nby jz6w), improvise-fdk1 (P1), improvise-fo0w (P2), improvise-1ohv (P2, blocked by\nfdk1), improvise-4n4i (P3), improvise-swzk (P3).\nALREADY FILED, not duplicated: improvise-7xyx (raised P3-\u003eP2: it is a black-hole key,\nnot just a dead field), improvise-hs0u (prefixes), improvise-gd28 (drill),\nimprovise-bd9o (records mode), improvise-uln5 (advertised-vs-resolvable).\nFOUR PRODUCT RULINGS OWED -- see the orchestrator's hand-off.","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:51:02Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:33:55Z","started_at":"2026-09-23T03:33:55Z","lease_expires_at":"2026-09-23T03:38:55Z","heartbeat_at":"2026-09-23T03:33:55Z","labels":["architecture","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-koh6","title":"[bug] Following the help overlay's N to add a category overwrites a data cell","description":"DEMONSTRATED end to end under Chromium at 523d571, on web/demo.improv, with\nthree controls (C, P, T) in the same loop that DID move -- otherwise a blind\nprobe and a black hole look identical.\n\n cell (0,0) before: \"18,400\"\n keys: N R e g i o n Enter\n cell (0,0) after: \"18400on\" \u003c- Espresso/Revenue, overwritten with text\n categories: UNCHANGED -- no category was created\n status: \"...records mode is not available in the browser yet\"\n\nTHE SEQUENCE, measured one key at a time:\n\n - `N` resolves Upstream. Host::handle answers a Key frame with an empty vec, so\n the FULL DOM snapshot -- mode, hint, status, grid, tiles, minibuffer -- is\n BYTE-IDENTICAL before and after. Completely silent.\n - The user then types the category name into NORMAL, where every character is a\n live Normal binding:\n R -\u003e status \"records mode is not available in the browser yet\"\n e, g -\u003e nothing\n i -\u003e INSERT on the grid cell under the cursor, PRE-FILLED with its value\n o, n -\u003e appended to that edit buffer\n - Enter commits.\n\n`N` is advertised by the browser's OWN help overlay, page 4:\n\"N -- Quick-add a new category (from anywhere)\".\n\nWHY THIS IS NOT THE MINIBUFFER BUG IT WAS REPORTED AS: the minibuffer works\nperfectly. Measured: C then n gives mode NEW CATEGORY, minibuffer \"new category:\"\nin yellow, 1280x28.6 at y=691, and each typed character appears live; Enter\ngives 'Added category \"Reg\"'. Verified in two more modes (F,n -\u003e \"formula:\" cyan;\nC,a -\u003e \"add item to Product:\" green). The user's \"typing blind\" was LITERAL -- a\ndead key followed by characters landing as commands.\n\nTHE CATEGORY THIS ADDS TO THE DOCTRINE: clients.md enumerates black holes as\nkeys that do nothing. A black hole that leaves the keyboard in a MUTATING mode\nis strictly worse and is not currently distinguished. The cost here is a\ncorrupted value, not a confused user.\n\nSECOND, COMPOUNDING PROBLEM: the decline messages actively mislead. Pressing N\nthen R answers \"records mode is not available in the browser yet\" -- confident,\ntrue, and about a different subject entirely, arriving adjacent in time to an\nunanswered gesture. clients.md already names the failure (\"a reader who is told\nsomething plausible stops looking\"); this is a new route to it.\n\nIS THERE A ONE-LINE FIX? NO, and the investigating agent resisted calling it one.\nSearchOrCategoryAdd::execute (command/cmd/search.rs:181) is:\n\n if !ctx.search_query.is_empty() { SearchNavigate(false).execute(ctx) }\n else { vec![SetPanelOpen{Category,true}, change_mode(AppMode::category_add())] }\n\nThe else-branch is exactly two commands the browser already composes elsewhere,\nand search_query is on ViewState, so the client HAS everything it needs. But a\nconditional arm makes N's outcome STATE-DEPENDENT, and\nenter_in_an_empty_view_panel_resolves_locally_rather_than_upstream\n(resolve.rs:1364) exists precisely to forbid making the host-needed list\nstate-dependent. So it is a decision, not a line.\n\nCHEAP INTERIM that stops the data loss without deciding that: one arm in\ndeclined() giving search-or-category-add its own true subject. That ends the\nsilence but NOT the sequel, because the user's next keystrokes still land in\nNORMAL where `i` opens an editor.\n","notes":"ALREADY FIXED by 7a44616, an ancestor of 7470b61. Stale-open -- the fifth ticket\nclosed by that single commit.\n\nresolve.rs:379 answers search-or-category-add in composed() in BOTH arms: the\nempty-query arm builds SetPanelOpen{Category,true} + ChangeMode(category_add),\nand the other declines out loud. So N is no longer Upstream, and therefore no\nlonger a black hole that left the keyboard in Normal where i opens an editor over\na data cell -- which was this ticket's whole mechanism.\n\nNOTE A MISREADING WORTH RECORDING: an earlier agent reported that\nsearch-or-category-add \"left the host-needed list WITH improvise-koh6\", which I\nread as a side effect of that PR. It was the whole close. A ticket named in\nanother ticket's departure note is easy to read as context rather than as\nresolution.\n\nNOT VERIFIED END TO END: the browser half was confirmed by reading the resolver\narm and its ancestry, not by driving Chromium -- harness/ was outside the\nverifying agent's file scope, and this ticket's original evidence was gathered\nthere.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:28Z","closed_at":"2026-09-22T07:40:28Z","close_reason":"Closed","labels":["browser","data-loss","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wft","title":"Browser boot failures have no diagnosis channel, so five different causes all read as silence","description":"Five open beads, filed independently by three different reviewers, are one\nfailure mode: the browser's boot can fail for several reasons and the page\neither says nothing specific or nothing at all.\n\n improvise-i0v9 OPFS handle held by another context (or a prior session)\n improvise-86nw start() rejects for a non-OPFS reason; onFrame then drops\n every frame while `server` is null\n improvise-9cma invalid ?doc= -- generic watchdog text only\n improvise-iobc bad or missing ?seed= -- silent empty document, CORS/404\n visible only in the console\n improvise-vep8 the watchdog arms AFTER `await transport.ready`, so a\n connection hang never arms it at all\n\nTHE SHARED ROOT: there is no channel carrying a boot failure's REASON from the\nworker to the page. gotcha 46 already records the mechanism -- a worker that\nrejects in start() fires NO `error` on the main thread, because worker.js\ncatches its own -- so the only survivor is a generic first-frame watchdog, and\nimprovise-vep8 shows that watchdog does not even arm in one case.\n\nThe result is the worst shape a failure can take, measured on i0v9: `#status`\nreads \"in-page worker - \u003cdoc\u003e\" at 13ms, which looks like SUCCESS, and 8 seconds\nlater is replaced by \"this browser may not allow it\" -- blaming the browser for\na condition the code knows precisely, since it is holding the exact\nNoModificationAllowedError naming the exact document.\n\nWHAT THIS TICKET IS: the diagnosis channel, not the five triggers. The worker\nmust be able to report a structured boot failure across postMessage, and the\npage must render it instead of a timeout. Each of the five then becomes a\nmatter of producing the right reason rather than of plumbing.\n\nTwo things to decide while building it, both raised by the triggers above:\n - a boot failure is a STATE, not a rejected promise (i0v9 needs the page to\n open and paint read-only, which means boot must succeed without a handle)\n - the watchdog stays, but as a LAST resort that is armed before anything can\n hang (vep8), and is disarmed rather than pre-empted by a precise failure\n (gotcha 48 already states that rule and PR #21 implemented half of it)\n\nNot a new investigation: every one of the five is already demonstrated. This\nexists so the five are not fixed five different ways.\n","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:26:34Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:54Z","closed_at":"2026-09-21T21:58:54Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nTHE CHANNEL: a protocol frame crosses the port as a STRING, a shell notice as an OBJECT. Total discriminator, zero protocol change, since ServerFrame JSON is a string by construction of on_message.\n\nA boot failure is now a STATE, not a rejected promise: a document another context holds the handle to opens READ-ONLY over vfs::ReadOnly rather than dying. Only BootFailed is fatal. The watchdog is armed BEFORE transport.ready and every notice routes through shell.fail, so gotcha 48's disarm half holds.\n\nMoving the watchdog forced its message to change, and that is load-bearing: armed before ready, it can no longer claim 'accepted the connection but sent no document' -- the page does not know that yet. The existing spec asserted the OLD wording and failed, which is the right way round and is how it was found.","labels":["architecture","browser","ux"],"dependency_count":0,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"improvise-55l5","title":"dispatch swallows interactive() errors silently","description":"## Summary\n\n`dispatch` uses `.ok()?` on `interactive()`, swallowing `Err` (argument parse failures, mode mismatch for `commit-buffer item`, etc.). The user presses a key and nothing happens — no status message. Violates `doctrine(an_upstream_key_is_a_black_hole)` for failures that *are* resolvable as user-visible errors.\n\n## Expected / Actual\n\n- **Expected:** Construction/resolve errors surface as status (or equivalent loud feedback).\n- **Actual:** `.ok()?` turns Err into silent no-op.\n\n## Evidence / verify\n\nBind a key to `commit-buffer item` in Normal mode (not ItemAdd). Press the key — command fails to construct with no status message.\n\n## Doctrine\n\n`doctrine(an_upstream_key_is_a_black_hole)` — silent no-ops that look like black holes when the failure is knowable.\n\n## Location\n\n`crates/improvise-command/src/command/keymap.rs` ~L37\n\n## PR / Attribution\n\nPR #100 F05. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"FIXED in PR #126 (merge 4625593).\n\nTHE CAUSE: dispatch spelled construction as\nregistry.interactive(name, args, ctx).ok()?, so Option::None carried TWO FACTS --\n\"no binding matched\" and \"a binding matched but would not build\" -- and\nApp::dispatch_key answers None with Dirty::NOTHING and no status. A bound key was\na black hole.\n\nTHE ERROR CLASSES, separated, which is the part worth keeping:\n\n CLASS A -- reaches a user, and NOTHING STATIC CAN SEE IT. \"no cell at cursor\",\n from six registrations that read ctx.cell_key(): clear-cell, yank-cell,\n paste-cell, drill-into-cell, commit-cell-edit, commit-and-advance-right. It is\n STATE-dependent, so no registration-time check applies. The two commit entries\n are the worst case: the user is in Editing, has typed a value, presses Enter,\n and the batch is discarded with the minibuffer still up -- where\n draw_bottom_bar paints INSTEAD of the status line (gotcha 21). This is the\n whole ticket.\n\n CLASS B -- already guarded and unreachable from a shipped keymap. \"Unknown\n command\", require_args, \"Unknown mode\", and \"commit-buffer item requires\n item-add mode\". every_bound_command_name_is_registered refuses all of these at\n test time, checking BOTH halves of the old expression precisely because\n improvise-s13 made a typo'd mode name compile into a dead key (gotcha 15).\n\n SO THE REPRO THIS TICKET ITSELF PROPOSED IS THE ONE CASE THAT CANNOT HAPPEN.\n\nTHE FIX: None means \"no binding matched\" and nothing else; a construction failure\nbecomes Some(vec![set_status(\"\u003cname\u003e: \u003cerr\u003e\")]). Class B surfaces too, and that is\ndeliberate -- a list of blessed errors would be a second description of which\nfailures matter, free to disagree with the first.\n\nTHE TEST PAIR IS THE INTERESTING PART, because one test alone cannot separate the\ntwo facts the Option used to carry:\n - rival \"return Some, swallow the error\": fails\n a_bound_key_whose_command_cannot_be_built_says_so; the control passes.\n - rival \"report a failed LOOKUP as a status too\": fails\n an_unbound_key_stays_silent; the other passes. Without that control, the fix\n would write a status for every unmapped character in every mode.\n\nNOT VERIFIED: whether x/y/p on an empty grid is reachable in a SHIPPED document.\nThe fixture constructs the state (an item-less category on Axis::Row) rather than\nreaching it by keystrokes. Real for a user mid-add-cat; not driven.\n\nFOLLOW-UP NOT FILED: a \"no cell at cursor\" refusal from commit-cell-edit still\nleaves the user in Editing with the minibuffer covering the status line. The\nmessage is now written; whether it is DRAWN in that mode is gotcha 21's rendering\ntrap and a separate question.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","updated_at":"2026-09-22T07:40:42Z","closed_at":"2026-09-22T07:40:42Z","close_reason":"Closed","external_ref":"gh-pr-100-F05","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -15,7 +17,7 @@ {"_type":"issue","id":"improvise-nxah","title":"Optimistic fold is not rolled back on server rejection","description":"## Summary\n\nWasm client folds commands optimistically in `Client::key` before sending. On server rejection (`Rejection::LayoutMoved`, etc.), `socket.rs::fold_frame` sets `last_rejection` / `status_msg` but does **not** roll back already-folded local state. Client `page_coords` (and other folded fields) can diverge from the server.\n\n## Expected / Actual\n\n- **Expected:** Rejected optimistic folds are undone (or client resyncs to server projection) so local state matches the accepted basis.\n- **Actual:** Rejection is reported; folded mutations remain.\n\n## Evidence / verify\n\nSend a command the server will reject (e.g. page-next after layout moved). Observe client state advanced while server did not.\n\nDistinct from improvise-yqpd (server-side all-or-nothing transactional rollback of effect batches / ADR 001 clause 4). This is client optimistic-fold rollback on rejection.\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `Client::key` ~L182-193; rejection handling in socket fold path\n\n## PR / Attribution\n\nPR #100 F02. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"WAVE 2026-09-21: NOT DONE, and the reason is a finding rather than a shortfall. improvise-nxah and improvise-3al1 are IN DIRECT CONTRADICTION and neither bead mentions the other. They are ONE design and should be scheduled as one.\n\nTHE CONTRADICTION, measured: nxah's naive fix is to snapshot ViewState before the optimistic fold and restore it on ServerFrame::Rejected. That WIPES ViewState::buffers -- which is precisely the user's typing that 3al1 requires be kept staged so a force can re-compose it. resolve.rs folds 9 Command::SetBuffer sites alongside 19 Command::Model sites, so a whole-slice rollback is wrong and WHICH FIELDS roll back is a design decision, not a mechanical fix.\n\nTHE SECOND BLOCKER, and it is a genuine protocol gap: there is NO POSITIVE ACKNOWLEDGEMENT. The server answers nothing on success, so a client cannot know when to DISCARD an undo snapshot. With two frames in flight a single pending slot rolls back the wrong one. This is the same shape as the publication gap ADR 001 originally omitted -- it said a batch carries the basis it was composed against and never said how a client learns one, which Projection::Basis had to close.\n\nSo the work is: decide what a rejected frame rolls back on the client given that buffers must survive, and decide how success is acknowledged. Then both beads fall out. Do not start either alone.","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-22T23:00:24Z","closed_at":"2026-09-22T23:00:24Z","close_reason":"Closed","external_ref":"gh-pr-100-F02","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-nxah","depends_on_id":"improvise-yqpd","type":"relates-to","created_at":"2026-09-21T20:49:19Z","created_by":"external-grok-bot","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zre1","title":"Auto-persist failure in worker not communicated to client","description":"## Summary\n\nWhen worker-server auto-persist (`effect::Save`) fails (OPFS quota, closed handle, etc.), the dirty flag stays `true` but the failure is only written to `status_msg` that the worker-server has no UI to show. The client sees projected `dirty: true` and cannot tell \"save failed\" from \"save pending\". Risk of data loss on page close if the user trusts auto-save.\n\n## Expected / Actual\n\n- **Expected:** Save failures are projected to the client as an explicit failure/status (not just dirty).\n- **Actual:** Dirty stays true; failure stays local to worker status_msg.\n\n## Evidence / verify\n\nInject a storage backend that fails writes after N successes; edit past the failure; observe dirty true with no save-failure indication on the client.\n\n## Location\n\n`crates/improvise-worker-server/src/worker.rs` — `on_frame` ~L52-59\n\n## PR / Attribution\n\nPR #100 F04. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:18Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nPersist failure raises a notice. The trigger is THE DIRTY FLAG SURVIVING persist() -- observed, not predicted from a table of commands, which is gotcha 45's shape. Fault injected with a real refusing backend; a companion test proves a success raises nothing.","external_ref":"gh-pr-100-F04","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-eiv2","title":"CSV import silently converts NaN/Infinity fields to zero","description":"## Summary\n\nCSV import silently converts NaN/Infinity field values to 0. `serde_json::Number::from_f64` returns `None` for NaN and Infinity; `parse_csv_field` uses `.unwrap_or(serde_json::Number::from(0))`, so `\"NaN\"`, `\"Infinity\"`, `\"-Infinity\"`, or `\"inf\"` import as numeric 0 with no warning — data is silently corrupted.\n\n## Expected / Actual\n\n- **Expected:** Refuse non-finite numbers, keep as text, or emit a loud import warning; never invent 0.\n- **Actual:** Non-finite parses become `Value::Number(0)` with no status/warning.\n\n## Evidence / verify\n\nImport a CSV with a field value `nan` or `inf` and observe it imports as 0 rather than text or a warning.\n\nRelated closed bead improvise-4yc fixed malformed numbers → 0.0 in `.improv` persistence; this is the CSV import path (`csv_parser.rs`), still open.\n\n## Location\n\n`crates/improvise-io/src/import/csv_parser.rs` — `parse_csv_field` ~L128-131\n\n## PR / Attribution\n\nPR #100 F01. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":1,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:48:17Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:26:04Z","closed_at":"2026-09-21T21:26:04Z","close_reason":"Duplicate of improvise-jc95, which is the same defect found independently the same day: parse_csv_field's .unwrap_or(serde_json::Number::from(0)) in crates/improvise-io/src/import/csv_parser.rs:133-136, so NaN/Infinity/overflowing tokens import as 0 with no warning.\n\nKeeping jc95 rather than this one because it names the exact lines and because PR #101's reproduction (csv_nonfinite_is_not_zero, covering NaN, inf, -inf and 1e999) already cites jc95. This bead's P1 rating was the better one and has been carried over: jc95 is now P1. Nothing from this report is lost -- the serde_json::Number::from_f64 mechanism is recorded on jc95.","external_ref":"gh-pr-100-F01","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s8h7","title":"[bug] i on an aggregate cell drills in the TUI and refuses in the browser, while help advertises it as edit","description":"DEMONSTRATED at 1f28868 by driving a real App through reduce_key and a real\nClientState through resolve on the same key and the same fixture\n(furnished_host() with Product moved to Axis::None, so cell(0,0).id is\nSome(Aggregate(..))):\n\n PROBE i: browser = Local[SetStatus(\"drilling into an aggregate needs the host\n -- not in the browser yet\")]\n | tui = mode RecordsNormal, status \"Drilled into cell: 1 rows\"\n\nEditOrDrill::execute (cmd/mode.rs) branches on regular_none and, when the cell\nis aggregated, DELEGATES TO DrillIntoCell -- a mode change, a new _Drill view, a\nwhole workflow. resolve.rs::edit_or_drill's CellId::Aggregate arm returns a\nstatus line instead.\n\nWhy this matters more than the \u003e decline already filed: i is the ADVERTISED EDIT\nKEY. help.rs:514 is\n keys(n, \"edit-or-drill\", Some(\u0026[\"editing\"]), \"Edit current cell\")\nand it PASSES every_advertised_command_answers_in_its_own_mode, because a\nSetStatus counts as \"answered\". So the guard is satisfied by a refusal, and the\nsecond, undocumented route into drill is invisible to it.\n\nOn web/demo.improv nothing regular sits on None, so it does not bite today. It\nbites the moment a user tiles a category to none, which is five keys away.\n\nA defensible interim is one line: make the Aggregate arm reuse declined()'s\nwording so the two doors at least say the same thing -- today a user cannot tell\nthat i and \u003e are the same missing feature. The real fix is addressed drill,\nwhich is ADR 001 layer 2 territory.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:17Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:39:17Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-s8h7","depends_on_id":"improvise-gd28","type":"blocks","created_at":"2026-09-22T00:17:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s8h7","title":"[bug] i on an aggregate cell drills in the TUI and refuses in the browser, while help advertises it as edit","description":"DEMONSTRATED at 1f28868 by driving a real App through reduce_key and a real\nClientState through resolve on the same key and the same fixture\n(furnished_host() with Product moved to Axis::None, so cell(0,0).id is\nSome(Aggregate(..))):\n\n PROBE i: browser = Local[SetStatus(\"drilling into an aggregate needs the host\n -- not in the browser yet\")]\n | tui = mode RecordsNormal, status \"Drilled into cell: 1 rows\"\n\nEditOrDrill::execute (cmd/mode.rs) branches on regular_none and, when the cell\nis aggregated, DELEGATES TO DrillIntoCell -- a mode change, a new _Drill view, a\nwhole workflow. resolve.rs::edit_or_drill's CellId::Aggregate arm returns a\nstatus line instead.\n\nWhy this matters more than the \u003e decline already filed: i is the ADVERTISED EDIT\nKEY. help.rs:514 is\n keys(n, \"edit-or-drill\", Some(\u0026[\"editing\"]), \"Edit current cell\")\nand it PASSES every_advertised_command_answers_in_its_own_mode, because a\nSetStatus counts as \"answered\". So the guard is satisfied by a refusal, and the\nsecond, undocumented route into drill is invisible to it.\n\nOn web/demo.improv nothing regular sits on None, so it does not bite today. It\nbites the moment a user tiles a category to none, which is five keys away.\n\nA defensible interim is one line: make the Aggregate arm reuse declined()'s\nwording so the two doors at least say the same thing -- today a user cannot tell\nthat i and \u003e are the same missing feature. The real fix is addressed drill,\nwhich is ADR 001 layer 2 territory.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-web3","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:17Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:43:55Z","started_at":"2026-09-23T03:31:32Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-s8h7","depends_on_id":"improvise-gd28","type":"blocks","created_at":"2026-09-22T00:17:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-uej8","title":"[bug] the category panel cursor does not follow a shrinking list, so d deletes the next category","description":"DEMONSTRATED in a real browser at 1f28868, found while probing improvise-g3no.\n\nAfter deleting the last item of an expanded category, cat_panel_cursor stays at\nits index and the row now under it is the NEXT CATEGORY'S HEADER. Observed live:\n\n GRP d on Valley -\u003e rows: .. \"item|West\", \"category selected|\u003e Store (2) R\", ..\n\nSo a user holding d to clear out a category deletes the next CATEGORY on the\nfollowing press. RemoveCategory is LayoutGeometry: Workbook::on_category_removed\nrewrites EVERY view's axis map, and Model::remove_category also drops every\nformula targeting it (gotcha 35). High blast radius, one keypress away, no\nconfirmation, no undo.\n\nSame class from the other side, also observed: with the cursor at its default 0,\nd deleted the virtual category _Dim outright. Deleting _Index/_Dim/_Measure may\nitself deserve a guard -- they are the categories that AppMode-agnostic code\nassumes always exist (gotcha 7, and GridLayout's records-mode auto-detection\nkeys off _Index on Row and _Dim on Column).\n\nThe cursor-follows-a-shrinking-list problem is general: any panel whose list can\nshrink under the cursor has it. Check the formula and view panels for the same\nshape rather than fixing the category panel alone.\n","notes":"ALREADY FIXED by 7a44616 (PR #114) -- verified an ancestor of ee0f57d by the orchestrator with git merge-base --is-ancestor, and verified IN BOTH DIRECTIONS by the agent dispatched to fix it: the named guards pass at HEAD on BOTH front ends (command::cmd::panel::tests::deleting_a_categorys_last_item_leaves_the_cursor_inside_that_category plus two siblings, mirrored in resolve::tests::), and deleting the retreat_from_deleted_item(ctx) call reproduces this ticket's exact symptom, with the guard naming it: 'the cursor stayed over a category header, so one more d removes a whole category'.\n\nStale-open: the work landed and nobody closed the ticket. It cost an agent dispatch. See doctrine(a_ready_ticket_may_already_be_fixed).","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:05Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:20Z","closed_at":"2026-09-22T06:39:20Z","close_reason":"Closed","labels":["browser","bug","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i0v9","title":"[bug] a second browser tab on the same document is dead and silent, and blames the browser","description":"DEMONSTRATED, real Chromium, two tabs in one context, at 1f28868.\n\n tab2 console: error: improvise worker: failed to start\n Error: createSyncAccessHandle(two-tab.improv):\n NoModificationAllowedError: Access Handles cannot be created if there is\n another open Access Handle or Writable stream associated with the same file.\n\nbindings.rs::boot awaits storage.attach(\u0026path) before anything else; attach\ncalls create_sync_access_handle, which is exclusive per file ACROSS CONTEXTS.\nThe failure is at attach, NOT at open: OpfsFs::open() (getDirectory) and\nrefresh() both succeed in the second tab. Only handle acquisition fails. This\nconfirms for the OPEN the half of improvise-vvl3 that was anticipated for the\nINDEX.\n\nWHAT THE SECOND TAB SHOWS, MEASURED:\n\n t=13ms #status \"in-page worker - two-tab.improv\" page blank, 0 grid tables\n t=8016ms #status \"the in-page worker did not answer -- this browser may not\n allow it (see console)\" still blank\n\n8.4s wall from navigation to the message. Two things wrong beyond its lateness:\n\n - It BLAMES THE BROWSER for a condition the code knows precisely. It holds the\n exact NoModificationAllowedError naming the exact document.\n - The first 8 seconds ACTIVELY REASSURE. \"in-page worker - \u003cdoc\u003e\" is written at\n 13ms and reads as success. The user watches a blank page under a green-looking\n status for eight seconds and is then told it is probably their browser's fault.\n\nDiagnosis chain, exactly as gotcha 46 predicts: start() rejects -\u003e worker.js's\nstarted.catch logs to console and nothing else -\u003e a worker that rejects in\nstart() fires NO error on the main thread -\u003e onFrame's `if (!server) return;`\nsilently drops every frame -\u003e only the generic first-frame watchdog is left.\n\nIT IS NOT READ-ONLY, IT IS DEAD, AND IT IS SILENT. Measured in the failed tab:\n\n SECOND after C: mode=CATEGORIES #categories present status=(watchdog message)\n\nThe client is a fully live ClientState. View-only keys still work: C changes the\nmode and paints an empty category panel. So the tab does not present as\nread-only -- it presents as WORKING and then swallows every write into a socket\nwith no peer.\n\nDOES IT RECOVER? The handle IS released on tab close (the browser tears the\nworker context down; no detach needed) and a RELOAD of the second tab then\nsucceeds. But the page never retries: no reconnect, no watchdog rearm. So\n\"read-only until the other tab closes\" is in practice \"dead until you reload\".\n\nNote OpfsFs::detach (opfs.rs:106) has NO CALLER anywhere in the workspace, and\nweb/ has no beforeunload/pagehide/visibilitychange handler. Neither turns out to\nmatter for this case -- recorded so nobody \"fixes\" this by adding one.\n","notes":"SECOND TRIGGER, from improvise-16af (closed as duplicate; filed independently by an external Grok Bot browser smoke the same day): the bug also reproduces WITHOUT a second tab -- reload the page after a prior session left a handle open. The investigation behind this bead only drove the two-tab case, so the reload path is unverified here and should be checked when fixing; it may or may not be the same window between context teardown and handle release.\n\nThat report also observed the status flipping between 'in-page worker - demo.improv' and the watchdog message, i.e. intermittency the two-tab case does not show.\nWAVE 2026-09-21: PARTIALLY FIXED, deliberately left open.\n\nCLOSED by improvise-1wft's diagnosis channel and VERIFIED END TO END in both engines: the two-tab case. The second tab now opens READ-ONLY over vfs::ReadOnly with a true message, instead of painting a reassuring status for 8 seconds and then blaming the browser.\n\nMEASURED, which the read-only design rested on and nobody had probed: getFile() + arrayBuffer() DOES work on a file another context holds a sync access handle to -- 32 correct bytes on both Chromium and Firefox. The probe FIRST asserts that a second createSyncAccessHandle is refused, so it cannot pass vacuously in an engine that does not enforce exclusivity. Those two refusal strings are now the fixtures for boot::is_locked, so the DOMException name match is evidence-backed rather than guessed.\n\nTWO GAPS REMAIN and they need their own work, not more channel:\n 1. The RELOAD-AFTER-PRIOR-SESSION trigger (carried over from improvise-16af). It is a teardown/release race and is not deterministically drivable in the harness.\n 2. NO UPGRADE PATH. Nothing retries when the other tab closes, so it is still 'read-only until you reload' -- now with a true message instead of a false one. improvise-12ha's option C needs this same retry, so the work is not wasted either way.","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:04Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:08Z","labels":["browser","bug","opfs"],"dependencies":[{"issue_id":"improvise-i0v9","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-g3no","title":"[bug] d on a _Measure formula target deletes nothing and reports success","description":"DEMONSTRATED, twice, at 1f28868.\n\n_Measure's items are \"explicit items UNION formula targets\" (gotcha 7). Profit\nin web/demo.improv exists ONLY because Formula { target: \"Profit\" } exists; it\nis not in the category's own item list. Model::remove_item calls\ncat.remove_item(name) -- which no-ops, there being no such item -- and then\ndeletes cells whose key names it, of which there are none because the value is\nderived. effective_item_names(\"_Measure\") then re-synthesises Profit from the\nsurviving formula, so the row comes straight back.\n\nThe separate and worse half is that both front ends LIE about it. Each composes\nan unconditional optimistic status beside the mutation:\n\n browser: resolve.rs::delete_at_cat_cursor\n [Command::Model(RemoveItem..), Command::SetStatus(\"Deleted item ..\")]\n TUI: cmd/panel.rs::DeleteCategoryAtCursor\n [effect::RemoveItem.., effect::set_status(\"Deleted item ..\")]\n\nSetStatus is ViewOnly, folded locally, never contradicted by the host. Note the\nordering is the OPPOSITE of gotcha 20's rule, where CommitBuffer emits the\nfallible effect LAST so its failure message wins. Here the fallible effect is\nfirst and the optimistic status wins.\n\nMODEL-LEVEL EVIDENCE (no browser):\n\n let mut wb = Workbook::new(\"T\");\n wb.add_item(\"_Measure\", \"Revenue\").unwrap();\n wb.add_item(\"_Measure\", \"Cost\").unwrap();\n wb.model.add_formula(parse_formula(\"Profit = Revenue - Cost\", \"_Measure\").unwrap());\n wb.remove_item(\"_Measure\", \"Profit\");\n // FAILS: reported success, row still there: [\"Revenue\", \"Cost\", \"Profit\"]\n\nBROWSER EVIDENCE (real Chromium, real worker, real OPFS, real keystrokes via\nharness/, driving C -\u003e j*n -\u003e Enter -\u003e j -\u003e d, no state hand-placed):\n\n MEASURE on: \"item selected|Profit\"\n MEASURE after d: _Measure (2) -- Cost, Profit \u003c- UNCHANGED\n MEASURE status: \"Deleted item 'Profit' from '_Measure'\" \u003c- false\n\nEverything else about d works and was checked: regular category on Row; on Page\nwith that item as the page selection; reached by mouse click rather than j;\ngrouped items and group members; ordinary _Measure items (Revenue really is\ndeleted). The ONLY failing input is a formula target.\n\nNOT BROWSER-SPECIFIC. The TUI runs the same Model::remove_item and composes the\nsame optimistic status. Confirm in the TUI before any fix says \"browser\".\n\nFiles: crates/improvise-core/src/model/types.rs::remove_item,\ncrates/improvise-core/src/workbook.rs::remove_item,\ncrates/improvise-protocol/src/resolve.rs::delete_at_cat_cursor,\ncrates/improvise-command/src/command/cmd/panel.rs::DeleteCategoryAtCursor\n","acceptance_criteria":"Two separable changes. Truthfulness first, it is the reportable half:\n\n1. TRUTHFULNESS. Model::remove_item returns (). Give it a Removed answer, or\n give effect::RemoveItem one, and let the failure message win -- the shape\n gotcha 20 already uses for CommitBuffer, whose rule this violates. Cheapest\n correct version: have the resolver and the Cmd refuse BEFORE composing\n anything, since both ends can already see the tree.\n\n2. CORRECTNESS. Deleting the row a user sees under _Measure should delete the\n FORMULA. The browser cannot: delete-formula-at-cursor is in declined()\n (resolve.rs:232). So the honest browser answer today is declined()-style\n wording -- \"Profit is a formula target; delete the formula instead\" -- and\n deleting formulas from the browser is a separate ticket.\n\nAcceptance: a failing test at the model level first (the snippet in the\ndescription), then the front ends. Both surfaces, one change.","notes":"ALREADY FIXED by 7a44616 (\"fix(panel): five category-panel and axis defects, four\nof them P1\"), an ancestor of 7470b61. Stale-open -- the fourth from that one\ncommit, after improvise-uej8 and improvise-3b2b.\n\nVERIFIED BY EXECUTION, not by reading. Model::remove_item now ends with\nself.formulas.retain(|f| !(f.target_category == cat_name \u0026\u0026 f.target ==\nitem_name)) -- the item-level twin of the sweep remove_category always had. A\nthrowaway probe at HEAD took effective_item_names(\"_Measure\") from\n[\"Revenue\",\"Cost\",\"Profit\"] to [\"Revenue\",\"Cost\"] across\nwb.remove_item(\"_Measure\",\"Profit\"). Probe deleted.\n\nAND THE TICKET'S SECOND HALF NEEDS NOTHING: because the removal now really\nhappens, the optimistic status both front ends compose is TRUE, so no ordering\nchange is owed for the \"reports success\" part.","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:38:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:27Z","closed_at":"2026-09-22T07:40:27Z","close_reason":"Closed","labels":["browser","bug","core","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -72,10 +74,11 @@ {"_type":"issue","id":"improvise-km8","title":"1.2 Fix Cargo.toml metadata","description":"Add missing package fields: description, repository, homepage, documentation, readme, keywords, categories, license. Description: 'Terminal pivot-table modeling in the spirit of Lotus Improv'. Keywords: tui, pivot, spreadsheet, data, improv. Categories: command-line-utilities, visualization.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:48Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:20:17Z","close_reason":"Metadata already present: description, license, repository, homepage, readme, keywords, categories all populated.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-902","title":"1.1 Audit and remove or update context/SPEC.md","description":"Compare context/SPEC.md against actual code. If spec contradicts code significantly, delete it. Optionally move salvageable conceptual content to docs/design-notes.md with a staleness disclaimer. Do not try to bring spec into sync.","status":"closed","priority":1,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:06:46Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:10:05Z","close_reason":"Audited SPEC.md against code. Found minor inaccuracies (storage internals, wizard step count, search mode representation) but no major architectural contradictions. Deleted SPEC.md since it was largely redundant with repo-map.md and design-principles.md. Salvaged product vision and non-goals into docs/design-notes.md with staleness disclaimer.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-tik","title":"Phase 1: Repository hygiene","description":"Raise the quality floor of what a stranger sees when they land on the repo. Subtasks cover spec audit, Cargo.toml metadata, publish-readiness, CSV audit, and example files.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:45Z","created_by":"Edward Langley","updated_at":"2026-04-09T07:22:34Z","closed_at":"2026-04-09T07:22:34Z","close_reason":"All 5 subtasks complete: SPEC.md audited, Cargo.toml metadata fixed, publish dry-run clean, CSV audit done, example files created.","dependencies":[{"issue_id":"improvise-tik","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T23:37:31Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-902","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-bv1","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-km8","type":"blocks","created_at":"2026-04-08T23:37:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-tik","depends_on_id":"improvise-n1h","type":"blocks","created_at":"2026-04-08T23:37:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6qet","title":"[bug] z collapses the neighbouring group and can never re-expand one: a GroupHeader is drawn but has no data row to address it from","description":"Found while replacing improvise-ub4o's vacuous involution test, which is why\nnothing said so: the old test compared execute() against an UNCHANGED context,\nso no assertion in the suite had ever run the command twice over applied state.\n\nMEASURED. Fixture: Month with Q1={Jan,Feb}, Q2={Apr,May} on Row, Type on Column,\ncursor at data row 0. Pressing `z` (toggle-group-under-cursor) three times:\n\n 1. names Q1, collapses it\n 2. names Q2, collapses it \u003c- NOT the group just collapsed\n 3. returns no effects at all \u003c- row_count() is 0\n\nMechanism. expand_category skips every item of a collapsed group, so the group\ncontributes zero AxisEntry::DataItem. ToggleGroupAtCursor resolves through\nGridLayout::row_group_for(ctx.selected.0), which is indexed by DATA row and walks\nback to the nearest preceding GroupHeader. A collapsed group therefore has no data\nrow from which its own header is the nearest preceding one -- so the cursor can\nname it only while it is expanded.\n\nTwo consequences, and the second is the one a user hits:\n\n - Pressing `z` twice collapses two groups rather than restoring one. The cursor\n did not move; the grid moved under it.\n - A collapsed group can be re-expanded only by `:toggle-group`, which names the\n group in text. From the grid there is no way back.\n\nThis is the third bead in a family about exactly that way back -- improvise-lhi4\n(prune_empty dropped the header) and improvise-0o6x (a leading header was never\ndrawn) both fixed the header's VISIBILITY. AxisEntry's doc comment says a\nGroupHeader is always visible \"so its label and collapse state show\". Both of\nthose are now true and neither makes the header ADDRESSABLE, which is the half\nleft over.\n\nNot fixed under ub4o, which is a test bead: the three tests landed there pin the\nbehaviour above as it stands (a_second_toggle_at_the_same_cursor_names_the_next_\ngroup_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_\nnothing_to_address), so whichever answer is chosen here must edit them, which is\nthe point -- they are where the decision will be visible.\n\nOPTIONS, none obviously right:\n\n (a) Let the cursor address a header. That means a cursor position that is not a\n data row, which row_group_for's whole signature (data_row -\u003e visual) is built\n to avoid, and which every other cursor-addressed command would then have to\n answer for. Expensive and wide.\n (b) Make ToggleGroupAtCursor remember: if the view has a collapsed group whose\n header is the nearest preceding one in the VISUAL entry stream at the\n cursor's visual position, expand that instead. Keeps the data-row cursor and\n reads the entry stream, which is how lhi4 was resolved -- from the entries,\n with no \u0026View.\n (c) A separate expand-all / cycle-collapse binding, leaving `z` alone.\n (d) Decide it is acceptable because `:toggle-group` exists, and record that here\n plus in the gotcha, so the next person stops re-finding it. The family of\n three beads is evidence somebody keeps re-finding it.\n\nWhichever is chosen, the guard must inject a rival that collapses a neighbour:\n\"some group is named\" is satisfied by the current behaviour.","notes":"USER RULING 2026-09-23, and it reframes the bug. \"When collapsing a group, I'd generally expect the cursor to move to whichever place the group collapsed into. In some cases, the right thing to do might be to attach an argument to the command that is used to invalidate later commands.\"\n\nSo this is NOT \"the header is not addressable\". It is \"THE CURSOR DID NOT FOLLOW THE CONTENT IT WAS ON\". The four options above are all answers to the wrong question, and the chosen shape is a FIFTH that none of them listed.\n\nDECIDED (user, from a presented fork): A COLLAPSED GROUP CONTRIBUTES ONE DATA ROW, WHOSE CELL IS AN AGGREGATE OVER THE GROUP.\n\nThe change is one statement. layout.rs:755 today reads:\n\n // Skip the data item if its group is collapsed.\n if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) { continue; }\n\nA collapsed group emits its GroupHeader and then skips EVERY item, contributing zero AxisEntry::DataItem -- that `continue` is the whole mechanism of this bug. Instead it should emit ONE DataItem for the group.\n\nWHY THIS SHAPE RATHER THAN THE OTHERS. It dissolves the problem instead of working around it:\n\n- The cursor lands on the collapsed group BY ARITHMETIC, with no special case. Fixture Q1={Jan,Feb}, Q2={Apr,May}, cursor at data row 0 (Jan). Collapse Q1: data rows become [Q1, Apr, May], so data row 0 IS Q1 -- exactly \"the place the group collapsed into\". Pressing z again re-expands it, so z is an involution again without anything being taught about involution.\n- NOTHING about ViewCursor, row_group_for or any cursor-addressed command changes. The alternative that literally delivered the ruling (a cursor that names a visual entry) would have changed row_group_for's signature and forced every cursor-addressed command to define what it does on a header row.\n- It is what a pivot table MEANS by a collapsed group: the group's totals in one row. Collapsing currently hides the rows and shows nothing in their place, which is the part that reads as broken.\n- Settability is already answered. ADR 002 puts CellId on the projected cell and grid_of decides it, so the row comes back as CellId::Aggregate and is non-editable in BOTH front ends with no second derivation. Do NOT re-derive editability from \"is this a group row\".\n\nTHE ORDERING CONSTRAINT THAT WILL DECIDE THE IMPLEMENTATION, and it is easy to miss. ToggleGroup is EffectKind::Model(LayoutGeometry) (verified, effect.rs:444). Gotcha 24: since improvise-ni6k the cursor is invalidated by OBSERVING the grid's frame, and apply_effects diffs the frame after every model effect INSIDE the loop. So a collapse RESETS the cursor unless the batch places it afterwards. The placement must therefore come AFTER the ToggleGroup effect in the emitted vec -- the same shape as improvise-o9uq, where \"the order is the entire fix\" and the injected rival was the SWAP rather than the omission. A test that only checks the cursor ends up right must be shown to fail with the two effects reversed.\n\nPART 2 OF THE RULING, FOLDED IN HERE rather than filed as a general mechanism (user's choice): ToggleGroup may carry the group it acted on, so a later command in the same batch can be invalidated by it. State honestly what that does and does not buy. A Binding::Sequence flattens into ONE effect vec (gotcha 21), so `z z` bound as a sequence composes both toggles against the SAME pre-batch context -- that case the argument would fix. TWO SEPARATE KEYPRESSES are a different batch each and were never affected. AND UNDER THE CHOSEN DESIGN THE SAME-BATCH CASE MAY ALSO STOP MATTERING, because the second toggle resolves against a grid where the collapsed group is an addressable row. So: implement the aggregate row FIRST, then check whether the invalidation argument still buys anything. If it does not, say so here and do not add it -- a mechanism with no reachable case is the guard-one-level-away shape this repo keeps paying for.\n\nWHAT ELSE MOVES, since row_count() grows by one per collapsed group:\n- CSV export. persistence/export.rs::render_csv builds with with_frozen_records, NOT new (improvise-iot6, gotcha 1) -- a collapsed group will now appear as a row in exports. Decide whether that is wanted and say so; it is arguably correct and is certainly a behaviour change with round-trip tests to update.\n- Column totals, the browser grid and the viewport arithmetic all read the same GridLayout, so they follow for free -- that is the argument for making the change THERE rather than in a renderer.\n- The three tests PR #156 landed pin the CURRENT behaviour (a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address). They MUST be edited -- that is deliberate, they are where this decision becomes visible.\n- collapsed_group_has_header_but_no_data_items in layout.rs asserts the premise being changed and must be inverted, not deleted.\n\nGUARD. The rival to inject is a collapse that names a NEIGHBOUR: \"some group is named\" is satisfied by the current broken behaviour. Assert the cursor's position after a collapse of a group that is NOT first on the axis, because a group at data row 0 is right by accident under both the old and new behaviour -- the same trap as a fixture whose expected count is 1.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-collapse","owner":"el-github@elangley.org","created_at":"2026-09-23T02:23:31Z","created_by":"agent-cmd2","updated_at":"2026-09-23T03:11:52Z","started_at":"2026-09-23T03:11:52Z","lease_expires_at":"2026-09-23T03:16:52Z","heartbeat_at":"2026-09-23T03:11:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lrrx","title":"View history survives a document swap: install_workbook clears cursors but not the back/forward stacks, so \u003c walks the old document's views in the new one","description":"Found in a post-merge adversarial review of PR #154 (improvise-swzk), head 5de61e48. improvise-8nd9 says ViewBack/ViewForward are \"correct today\" because `ViewState::forget_view` purges both stacks when a view is deleted, and says the class returns \"the moment a second path can put a dead name on a stack\". That second path already exists.\n\n`App::install_workbook` (reached from LoadModel, AcceptWizardModel, and the third WholeWorkbook effect) resets `view_state.active_view` and clears `view_state.cursors`, because \"the incoming workbook's view names have nothing to do with the outgoing one's\" (gotcha 24). It does NOT clear `view_back_stack` or `view_forward_stack`. So the history of the old document survives into the new one, and `\u003c`/`,` walk frames that name the OUTGOING document's views.\n\nCONFIRMED with the headless CLI at gh/main 40b8913:\n- B.improv has views Default and Y, with `Initial View: Y`, and Region North/South/East on row. A.improv has views Default and X.\n- Control: `switch-view X . load B.improv . hide-selected-row-item . w ctl.improv` -f A.improv writes `hidden: Region/North` under `## View: Y`, the view B opened on.\n- Subject: `switch-view X . load B.improv . view-back . hide-selected-row-item . w out.improv` -f A.improv writes `hidden: Region/North` under `## View: Default`. `\u003c` walked A's history into B's Default view, which this client never visited in B. Exit 0 and no status.\n\nWhen the old frame names a view the new document lacks, ViewBack pushes the current view onto the forward stack and then `switch_to_view` refuses. That is 8nd9's phantom frame, and a `\u003c` that visibly does nothing. This follows from the same code (PLAUSIBLE, not run separately).\n\nRepair shape: clear both stacks in `install_workbook` beside `cursors.clear()`, for the same reason. Or give ViewBack/ViewForward swzk's observe-after shape, which is 8nd9. Either way, the regression test needs a fixture where the frame's name EXISTS in the incoming document but is not where it opened (as above). Otherwise a refused switch looks the same as \"nothing happened\".\n\nConnected-client mirrors are not affected: `Sessions::rebase` sends a fresh Snapshot, which replaces ViewState whole. This is the host/TUI's own slice.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T03:38:04Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T03:38:04Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6qet","title":"[bug] z collapses the neighbouring group and can never re-expand one: a GroupHeader is drawn but has no data row to address it from","description":"Found while replacing improvise-ub4o's vacuous involution test, which is why\nnothing said so: the old test compared execute() against an UNCHANGED context,\nso no assertion in the suite had ever run the command twice over applied state.\n\nMEASURED. Fixture: Month with Q1={Jan,Feb}, Q2={Apr,May} on Row, Type on Column,\ncursor at data row 0. Pressing `z` (toggle-group-under-cursor) three times:\n\n 1. names Q1, collapses it\n 2. names Q2, collapses it \u003c- NOT the group just collapsed\n 3. returns no effects at all \u003c- row_count() is 0\n\nMechanism. expand_category skips every item of a collapsed group, so the group\ncontributes zero AxisEntry::DataItem. ToggleGroupAtCursor resolves through\nGridLayout::row_group_for(ctx.selected.0), which is indexed by DATA row and walks\nback to the nearest preceding GroupHeader. A collapsed group therefore has no data\nrow from which its own header is the nearest preceding one -- so the cursor can\nname it only while it is expanded.\n\nTwo consequences, and the second is the one a user hits:\n\n - Pressing `z` twice collapses two groups rather than restoring one. The cursor\n did not move; the grid moved under it.\n - A collapsed group can be re-expanded only by `:toggle-group`, which names the\n group in text. From the grid there is no way back.\n\nThis is the third bead in a family about exactly that way back -- improvise-lhi4\n(prune_empty dropped the header) and improvise-0o6x (a leading header was never\ndrawn) both fixed the header's VISIBILITY. AxisEntry's doc comment says a\nGroupHeader is always visible \"so its label and collapse state show\". Both of\nthose are now true and neither makes the header ADDRESSABLE, which is the half\nleft over.\n\nNot fixed under ub4o, which is a test bead: the three tests landed there pin the\nbehaviour above as it stands (a_second_toggle_at_the_same_cursor_names_the_next_\ngroup_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_\nnothing_to_address), so whichever answer is chosen here must edit them, which is\nthe point -- they are where the decision will be visible.\n\nOPTIONS, none obviously right:\n\n (a) Let the cursor address a header. That means a cursor position that is not a\n data row, which row_group_for's whole signature (data_row -\u003e visual) is built\n to avoid, and which every other cursor-addressed command would then have to\n answer for. Expensive and wide.\n (b) Make ToggleGroupAtCursor remember: if the view has a collapsed group whose\n header is the nearest preceding one in the VISUAL entry stream at the\n cursor's visual position, expand that instead. Keeps the data-row cursor and\n reads the entry stream, which is how lhi4 was resolved -- from the entries,\n with no \u0026View.\n (c) A separate expand-all / cycle-collapse binding, leaving `z` alone.\n (d) Decide it is acceptable because `:toggle-group` exists, and record that here\n plus in the gotcha, so the next person stops re-finding it. The family of\n three beads is evidence somebody keeps re-finding it.\n\nWhichever is chosen, the guard must inject a rival that collapses a neighbour:\n\"some group is named\" is satisfied by the current behaviour.","notes":"USER RULING 2026-09-23, and it reframes the bug. \"When collapsing a group, I'd generally expect the cursor to move to whichever place the group collapsed into. In some cases, the right thing to do might be to attach an argument to the command that is used to invalidate later commands.\"\n\nSo this is NOT \"the header is not addressable\". It is \"THE CURSOR DID NOT FOLLOW THE CONTENT IT WAS ON\". The four options above are all answers to the wrong question, and the chosen shape is a FIFTH that none of them listed.\n\nDECIDED (user, from a presented fork): A COLLAPSED GROUP CONTRIBUTES ONE DATA ROW, WHOSE CELL IS AN AGGREGATE OVER THE GROUP.\n\nThe change is one statement. layout.rs:755 today reads:\n\n // Skip the data item if its group is collapsed.\n if item_group.is_some_and(|g| view.is_group_collapsed(cat_name, g)) { continue; }\n\nA collapsed group emits its GroupHeader and then skips EVERY item, contributing zero AxisEntry::DataItem -- that `continue` is the whole mechanism of this bug. Instead it should emit ONE DataItem for the group.\n\nWHY THIS SHAPE RATHER THAN THE OTHERS. It dissolves the problem instead of working around it:\n\n- The cursor lands on the collapsed group BY ARITHMETIC, with no special case. Fixture Q1={Jan,Feb}, Q2={Apr,May}, cursor at data row 0 (Jan). Collapse Q1: data rows become [Q1, Apr, May], so data row 0 IS Q1 -- exactly \"the place the group collapsed into\". Pressing z again re-expands it, so z is an involution again without anything being taught about involution.\n- NOTHING about ViewCursor, row_group_for or any cursor-addressed command changes. The alternative that literally delivered the ruling (a cursor that names a visual entry) would have changed row_group_for's signature and forced every cursor-addressed command to define what it does on a header row.\n- It is what a pivot table MEANS by a collapsed group: the group's totals in one row. Collapsing currently hides the rows and shows nothing in their place, which is the part that reads as broken.\n- Settability is already answered. ADR 002 puts CellId on the projected cell and grid_of decides it, so the row comes back as CellId::Aggregate and is non-editable in BOTH front ends with no second derivation. Do NOT re-derive editability from \"is this a group row\".\n\nTHE ORDERING CONSTRAINT THAT WILL DECIDE THE IMPLEMENTATION, and it is easy to miss. ToggleGroup is EffectKind::Model(LayoutGeometry) (verified, effect.rs:444). Gotcha 24: since improvise-ni6k the cursor is invalidated by OBSERVING the grid's frame, and apply_effects diffs the frame after every model effect INSIDE the loop. So a collapse RESETS the cursor unless the batch places it afterwards. The placement must therefore come AFTER the ToggleGroup effect in the emitted vec -- the same shape as improvise-o9uq, where \"the order is the entire fix\" and the injected rival was the SWAP rather than the omission. A test that only checks the cursor ends up right must be shown to fail with the two effects reversed.\n\nPART 2 OF THE RULING, FOLDED IN HERE rather than filed as a general mechanism (user's choice): ToggleGroup may carry the group it acted on, so a later command in the same batch can be invalidated by it. State honestly what that does and does not buy. A Binding::Sequence flattens into ONE effect vec (gotcha 21), so `z z` bound as a sequence composes both toggles against the SAME pre-batch context -- that case the argument would fix. TWO SEPARATE KEYPRESSES are a different batch each and were never affected. AND UNDER THE CHOSEN DESIGN THE SAME-BATCH CASE MAY ALSO STOP MATTERING, because the second toggle resolves against a grid where the collapsed group is an addressable row. So: implement the aggregate row FIRST, then check whether the invalidation argument still buys anything. If it does not, say so here and do not add it -- a mechanism with no reachable case is the guard-one-level-away shape this repo keeps paying for.\n\nWHAT ELSE MOVES, since row_count() grows by one per collapsed group:\n- CSV export. persistence/export.rs::render_csv builds with with_frozen_records, NOT new (improvise-iot6, gotcha 1) -- a collapsed group will now appear as a row in exports. Decide whether that is wanted and say so; it is arguably correct and is certainly a behaviour change with round-trip tests to update.\n- Column totals, the browser grid and the viewport arithmetic all read the same GridLayout, so they follow for free -- that is the argument for making the change THERE rather than in a renderer.\n- The three tests PR #156 landed pin the CURRENT behaviour (a_second_toggle_at_the_same_cursor_names_the_next_group_not_the_collapsed_one, a_fully_collapsed_row_axis_leaves_the_command_nothing_to_address). They MUST be edited -- that is deliberate, they are where this decision becomes visible.\n- collapsed_group_has_header_but_no_data_items in layout.rs asserts the premise being changed and must be inverted, not deleted.\n\nGUARD. The rival to inject is a collapse that names a NEIGHBOUR: \"some group is named\" is satisfied by the current broken behaviour. Assert the cursor's position after a collapse of a group that is NOT first on the axis, because a group at data row 0 is right by accident under both the old and new behaviour -- the same trap as a fixture whose expected count is 1.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-collapse3","owner":"el-github@elangley.org","created_at":"2026-09-23T02:23:31Z","created_by":"agent-cmd2","updated_at":"2026-09-23T03:43:52Z","started_at":"2026-09-23T03:11:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-obtu","title":"The aggregate-addressing seam: a view-naming command resolved against that view's layout","description":"The remaining improvise-gd28 work, now that the blocker is correctly diagnosed (see improvise-gd28 and gotcha 44).\n\nAn AggregateHash is layout-scoped and cannot be otherwise, so App::resolve_aggregate answers only for the host's active view -- which since improvise-jytm is not the client's. A browser holding CellId::Aggregate(h) therefore cannot get it resolved.\n\nTHE SHAPE, and it is not a new mechanism: let the command NAME ITS VIEW, exactly as ModelCommand::SetAxis { view: String, .. } already does, and resolve the surrogate against a layout derived for that name rather than against app.layout. grid_in already derives a layout for a session on a view the host is not on, so the machinery exists.\n\nExplicitly NOT the alternative: projecting the coordinates to the client. That is precisely what ADR 002 exists to prevent -- an opaque surrogate stops being opaque and the client starts holding document coordinates.\n\nExplicitly NOT needing a SessionId: the mutation a drill entry performs is addressed to NO ONE session -- it configures the shared _Drill view exactly as CreateView/SetAxis already do unaddressed. Only the RESOLUTION of its operand is session-scoped. That asymmetry is the whole finding.\n\nAlso not needed: improvise-fdk1. 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 the user's \"staging scheme should be identical\" ruling is kept by the browser getting no edits rather than by a second scheme.\n\nNote the contention issue filed separately: _Drill is one view in Workbook::views, so two clients drilling contend.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t0ma","title":"records_steps is guarded by one incidental test","description":"records_steps (improvise-core/src/view/layout.rs) is guarded by exactly ONE test in the whole workspace, and that test is incidental: injecting is_index_or_dim() -\u003e is_measure() into records_steps failed only save_with_unfinished_record_is_readable, which is a persistence test that happens to enter records mode. Measured during PR #140.\n\nThat is thin for the spine GridLayout auto-detects records mode by (_Index on Row, _Dim on Column). A rival that got the spine wrong in a way that persistence does not observe would ship.\n\nWanted: a step-equivalence test asserting records_steps(model) produces the spine plus exactly one Axis::None step per non-index/dim category, and drill_steps likewise. It must be a test OF THE STEPS, not of the resulting effects -- the effects are cmd/grid.rs's translation and are already covered; the point of the split is that the steps are the model-side answer a browser will also consume.\n\nInject the is_measure() rival and show it fails the new test. Asked for by the ChatGPT Codex review on PR #140.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:24:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-up8g","title":"Viewport extent is an external input on BOTH front ends, and it changes which record an edit writes","description":"The viewport extent is an EXTERNAL INPUT that reaches the document, and it is not a browser problem -- the TUI has exactly the same hole and always did.\n\nrun_headless_commands reads crossterm::terminal::size() into app.term_{width,height}, and CmdContext::{visible_rows, visible_cols} derive from it. Measured: term 80x24 -\u003e visible_rows=16; term 200x60 -\u003e visible_rows=52.\n\nAND IT REACHES THE DOCUMENT. Probe: a 40-row single-category grid, identical command sequence, one PageDown then clear-cell at the cursor:\n term 80x24 -\u003e selected=(12,0) -\u003e cleared Row=R12\n term 200x60 -\u003e selected=(39,0) -\u003e cleared Row=R39\nSame commands, DIFFERENT RECORD DELETED.\n\nSo Client::set_viewport is not a browser-introduced external input; it is the browser's SPELLING of an input the TUI has had all along. Gotcha 47's \"not derivable\" is true of BOTH front ends. Any ADR that treats extent as a browser problem will produce a log that replays in a browser and not in a terminal.\n\nTHE INTERACTION THAT MAKES THIS URGENT: today this hazard is INVISIBLE in `improvise script`, because the script path discards the cursor entirely (improvise-6uva). Fixing 6uva makes this live. The extent must be in the log -- or pinned by the replay harness -- IN THE SAME CHANGE that restores context-aware script parsing, or the repair will look like it works and then be flaky.\n\nThis is the cheapest of the five external inputs to close: it is one pair of integers and it already has a wire spelling (ClientFrame::Viewport). Make it a logged command (set-extent \u003crows\u003e \u003ccols\u003e) emitted on resize. The TUI has no such command today -- that is the only new vocabulary needed, and gotcha 42 already argues the extent is deployment-independent.\n\nA regression test must drive TWO sizes and assert the edit lands on the same record. One size cannot see this.\n\nFound by the improvise-ilvl replay audit.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:24:54Z","comments":[{"id":"43b16194-af3a-50b5-8265-105b872d2cde","issue_id":"improvise-up8g","author":"agent-review-cmd","text":"NOW LIVE, confirmed by a post-merge review of PR #151 (improvise-6uva, merged as 57be9144). #151 restored context-aware script parsing without logging or pinning the extent in the same change, which is exactly what this ticket asked for.\n\nProbe: a document with one category Region and 60 items, run under a pty through `script -q /dev/null` with `stty rows N`:\n improvise cmd 'page-scroll 1 . hide-selected-row-item' -f doc.improv\n- gh/main 40b8913, 24 rows: hidden: Region/R13\n- gh/main 40b8913, 50 rows: hidden: Region/R32\n- pre-#151 f7573bff, 50 rows: hidden: Region/R01. The cursor was always reset to the origin, so the result did not depend on the terminal.\nThe same script run against the same document writes a different document depending on the height of the terminal that ran it. With no tty (CI, cron), crossterm::terminal::size() fails and the app keeps 80x24, so CI and an interactive shell also disagree.\n","created_at":"2026-09-23T03:36:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-ilvl","title":"ADR 006: everything is a command, and a recorded sequence replays the whole system","description":"USER RULING 2026-09-22: \"No action anywhere should happen without firing a\ncommand: like with emacs, everything is a command. But I should be able to\nreproduce any state of the whole system by spinning up a fresh copy of an\napplication and replaying a recorded sequence of commands.\"\n\nTWO CLAUSES, and the second is much stronger than the first. \"Everything is a\ncommand\" is a discipline. \"Replay reproduces any state\" is a TESTABLE PROPERTY,\nand it is the one that makes the discipline enforceable rather than aspirational.\n\nWHAT THE TREE ALREADY GIVES US, measured:\n - The Cmd/Effect pipeline IS the Emacs shape: a named command in a registry,\n resolved against a read-only CmdContext, returning effects that are the only\n mutation site. Nothing about that needs inventing.\n - `improvise script \u003cfile\u003e` ALREADY REPLAYS a command sequence into a fresh\n process (src/main.rs::run_headless_script), and PR #131 made it and\n `improvise cmd` one function so they cannot diverge. So half the machinery\n exists and is shipped.\n - Determinism work is largely done and was done for this reason without\n knowing it: gotcha 18 (DataStore iteration order, analyze_records first-seen\n order) and gotcha 37b (a gp fit records seed AND FitConfig, because the same\n seed under a different config lands on a different tree).\n - THERE IS NO RECORDER. parse.rs parses a script; nothing writes one.\n\nSO THE WORK IS (a) the recording side, and (b) closing the paths that bypass the\ncommand layer.\n\nTHE HARD QUESTION, WHICH THE ADR MUST ANSWER FIRST: WHICH VOCABULARY IS RECORDED?\nThere are THREE command-ish layers and they are not interchangeable:\n 1. `Cmd` -- a NAME plus args, resolved against a CmdContext. The Emacs\n analogue, and what `improvise script` already replays.\n 2. `Effect` -- the resolved mutation, the only thing that touches App.\n 3. `Command`/`ModelCommand` -- the WIRE vocabulary, derived from the effects.\n\nGotcha 38 draws the line that matters: A KEYMAP LOOKUP CANNOT RETURN A `Command`,\nbecause a binding names an INTENT resolved against a CmdContext (\"move-cursor\ndown\") while a Command is the RESOLVED MUTATION (\"SetSelected{row,col}\").\nimprovise-gxi's AC 4 asked for something that split forbids.\n\nThat cuts both ways for replay:\n - Recording layer 1 is small, human-readable and editable, and replays only if\n the CONTEXT evolves identically -- which is the Emacs model and is fine from\n a known start state.\n - Recording layer 2 or 3 is robust to context drift but is not \"commands\" in\n the user's sense, and a log of SetSelected{3,4} is not something a person\n reads or edits.\nThe ADR should probably choose layer 1 and say what that costs.\n\nWHAT BREAKS REPLAY TODAY -- enumerate before designing:\n - ANY ACTION THAT DOES NOT GO THROUGH A COMMAND. Audit for it: the browser's\n click surfaces compose Commands (Click::PageItem does), but viewport/resize,\n the ResizeObserver, and the OPFS persist trigger are observations rather than\n commands. Decide whether an observation must become a command or is outside\n the log by construction.\n - EXTERNAL INPUT. LoadModel(path) and the import wizard read files; replay\n reproduces the state only given the same inputs. Does the log reference them,\n or must it capture them?\n - TIME AND RANDOMNESS. A gp fit already records seed + config; anything else\n that samples a clock or an RNG has to.\n - MULTI-CLIENT. \"The state of the whole SYSTEM\" with several sessions means the\n log has to carry WHO acted, which is ADR 001's addressed-command question\n from a new direction -- and gotcha 29 says the model is shared and the UI is\n not, so a replay reproducing every client's UI needs per-session logs.\n\nWHAT THE PROPERTY BUYS, and why it is worth the ADR: a replay harness is the\nstrongest possible regression test for this codebase -- record a session, replay\nit, diff the resulting document. It would have caught the whole class this repo\nkeeps rediscovering (a test that passes while the thing it names is broken),\nbecause a replay diff cannot be vacuous.\n\nDO NOT START BY WRITING A RECORDER. Start by auditing what currently bypasses the\ncommand layer, because that list decides whether the property is reachable at all.","notes":"TWO REFINEMENTS FROM THE USER, 2026-09-22, and the second changes what this bead\nis:\n\n \"(Ignoring nondeterminism like rng seeding)\"\n \"This was how the app used to work, but maybe the web refactor broke it\"\n\nSO RNG/CLOCK DETERMINISM IS OUT OF SCOPE. Strike that bullet from the enumeration\nabove; a gp fit's seed+config recording (gotcha 37b) is a separate concern and\nalready handled.\n\nAND THIS IS ARCHAEOLOGY, NOT DESIGN. The property existed. The first task is to\nfind WHEN and WHERE it stopped holding, not to invent a mechanism. That reframes\nthe whole bead: start with `git log` and a bisect-shaped question, not with a\nrecorder.\n\nWHAT THE WEB WORK INTRODUCED THAT IS A CANDIDATE, found in a first pass:\n\nThe browser refactor deliberately replaced several hand-kept tables with\nOBSERVATIONS, and each is a state change that happens WITHOUT A COMMAND FIRING:\n - App::document_generation += 1 (app.rs:444, bumped by install_workbook)\n - App::cursor_epoch += 1 (app.rs:653, on cursor invalidation)\n - App::layout_version += 1 (app.rs:945, per LayoutGeometry effect)\n - view_frame / view_frames (app.rs:73-124) -- the cursor is invalidated\n by OBSERVING the grid's frame (gotcha 24, improvise-ni6k), which is why no\n effect calls reset_cursor any more\n - model_state.dirty raised in apply_effects off EffectKind (gotcha 45), which\n is why MarkDirty was DELETED as an effect\n\nEACH OF THOSE WAS A DELIBERATE MOVE AND THE ARGUMENT FOR IT IS GOOD: a table of\n\"which effects renumber the grid\" is a second description free to disagree with\nthe first, so the repo chose to observe instead. Gotchas 24, 39 and 45 all say\n\"observed, not predicted\" in as many words.\n\nSO THERE IS A REAL TENSION TO RESOLVE IN THE ADR, and it is the interesting part:\n\"everything is a command\" and \"observe rather than predict\" pull in opposite\ndirections.\n\nBUT PROBABLY NOT A CONTRADICTION, and the ADR should test this claim first\nbecause it may dissolve the whole problem: AN OBSERVATION IS A PURE FUNCTION OF\nSTATE. If replaying the commands reproduces the state, every observation\nre-derives identically and nothing is lost. Observations are DERIVED, not\nprimitive, so they do not need to be in the log -- they need to be REPRODUCIBLE\nFROM it. That is a weaker and much cheaper requirement.\n\nWHAT WOULD GENUINELY BREAK REPLAY is an action whose result is NOT determined by\nthe command sequence. The candidates to audit, in order:\n - VIEWPORT EXTENT. Client::set_viewport records what the PAGE announced, off\n the browser window size and a ResizeObserver. That is external input, not a\n command, and gotcha 47 says it is \"not derivable\". A replay on a different\n window size gets a different extent -- and therefore a different projected\n grid.\n - EXTERNAL FILES. LoadModel(path) and the import wizard read the filesystem;\n replay reproduces state only given the same inputs.\n - MULTI-CLIENT ORDERING. \"The state of the whole system\" with several sessions\n needs the log to carry WHO acted and in what order -- ADR 001's addressed-\n command question from a new direction.\n\nFIRST CONCRETE STEP: `improvise script` already replays a command sequence into a\nfresh process (src/main.rs::run_headless_script, one function with\nrun_headless_commands since PR #131). So the check is cheap -- take a document,\ndrive it with a script, and see whether the result is reproducible. If it is, the\nTUI path still holds the property and the regression is browser-side only, which\nnarrows the search to the list above.\nAUDIT COMPLETE 2026-09-22 (read-only, base a9cad15). Baseline measured on the auditing host: 1699 passed / 0 failed / 7 ignored over 38 binaries, longest single binary 0.70s.\n\nTHE USER'S HYPOTHESIS IS WRONG FOR THE MAIN DEFECT. Replay is broken, but not by the web refactor: see improvise-6uva, broken by 35946af on 2026-04-04, five months before the first wasm-client commit.\n\nTWO DEFECTS FOUND, FILED SEPARATELY: improvise-6uva (script cannot move the cursor) and improvise-up8g (viewport extent is external on both front ends). THE SECOND IS MASKED BY THE FIRST -- fix 6uva and up8g becomes live, so they must be closed together or the repair will look like it works and then be flaky.\n\nTHE TICKET'S STATED SAFETY ARGUMENT IS FALSE, and the false version is dangerous. The ticket claims \"an observation is a pure function of state\". IT IS NOT -- it is a pure function of the TRAJECTORY. Measured, two routes to an IDENTICAL document:\n no commands -\u003e layout_version 0, cursor_epoch 0, dirty false\n set-axis Product column, then set-axis Product row -\u003e layout_version 2, cursor_epoch 2, dirty true\nThe weaker true claim (\"reproducible from the log\") is all replay needs. But the stronger false one is exactly what you would cite to justify SNAPSHOT-PLUS-SUFFIX LOG COMPACTION, and that would be 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. model_state.dirty is the same shape and is the worker's OPFS persist trigger (gotcha 45), so a replay reaching the same document by a different route persists a different number of times.\n\nGOTCHA 40 IS STALE, and the truth is stronger than the doc. It says layout_version bumps \"per ModelChange::LayoutGeometry effect\". It actually bumps from an OBSERVED view_shapes diff (observe_reshape, app.rs:938), so three idempotent set-axis calls bump nothing and batching two real changes into one line or two gives the same 2. The observed form is what the ADR should lean on. Correction applied to context/repo-map.md.\n\nDETERMINISM OTHERWISE CHECKS OUT: no clock or RNG is reachable from any command or effect except App::last_autosave (Instant::now(), debounce only, writes to a separate autosave_path and never the document), and fit-measure takes its seed as an explicit argument with a fixed default. One genuine clause-1 violation that costs replay nothing: autosave_if_needed writes a file on a wall-clock timer with NO COMMAND FIRING, so \"everything is a command\" is literally false there.\n\nTHE FIVE GENUINE EXTERNAL INPUTS, ranked by cost to close:\n1. Viewport extent (improvise-up8g) -- cheapest, one pair of integers, already has a wire spelling.\n2. Document identity at startup (get_initial_workbook, -f \u003cpath\u003e) -- cheap: the log header names the starting document by content hash.\n3. External files mid-session (LoadModel, ExportCsv, SaveAs, ImportJsonHeadless, StartImportWizard) -- moderate. Storage is ALREADY the seam (gotcha 2), so a Storage that replays recorded bytes is a clean mechanism needing no effect change.\n4. Import-wizard interaction -- moderate/hard: the keys go through a command but the wizard state is session-local residue with no Projection. Replayable in principle; nothing tests it.\n5. Multi-client ordering and authorship -- HARDEST, and partly a design question. apply_model's basis check means a differently-interleaved replay can REJECT a batch the original accepted, so the log must carry SessionId AND the arrival order across sessions.\n\nWHICH VOCABULARY TO RECORD -- the ADR's central question. The auditor's reading, offered for the ADR to accept or reject: the user's ruling has two clauses wanting different layers. Clause 1 (\"everything is a command, like Emacs\") is a LAYER-1 discipline about what a user can type, bind and read. Clause 2 (\"replay reproduces any state\") is a LOG property, and LAYER 3 (Command/ModelCommand) is strictly better at it -- already Serialize/Deserialize, already carries a Basis, already has one door and one interpreter, already transactional via ClientFrame::Commands, and ALREADY WHAT THE BROWSER EMITS. Note a layer-1 log CANNOT BE PRODUCED BY THE BROWSER AS BUILT: gotcha 38 says a keymap lookup cannot return a Command, so the browser resolves keys to layer 3 and never constructs a layer-1 name.\nThe two are not in conflict if the log records layer 3 and layer 1 keeps its status as the human surface, with ExecuteCommand being just another producer of layer-3 traffic. WHAT THAT COSTS is closing layer 3's three DELIBERATE holes -- ApplyAndClearDrill and AcceptWizardModel off the wire (gotcha 38), ClientFrame::Save a frame not a command (gotcha 45), SwitchView carrying no basis -- each of which has a written argument that would have to be REOPENED. That is the real price and belongs in the ADR rather than being discovered later.\nLayer 2 (Effect) declined: 58 types, no text syntax, no parser, no Serialize, and a log of effects cannot be edited by a person, which is most of what the user asked for.\n\nNOT VERIFIED: the browser half was not driven (no Playwright, no wasm build) -- everything about Client::set_viewport is read, not run, though the TUI analogue WAS run and is the same mechanism. Multi-client interleaving was not exercised. The import wizard was not replayed. No bisect between 35946af and HEAD for ADDITIONAL regressions -- a second archaeology pass after 6uva is fixed is cheap and is where to look next. Only two terminal sizes probed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:25:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-f9qf","title":"Pin the shell: macOS bash 3.2 hid two bugs that failed 17/19 in CI on the same commit","description":"USER RULING 2026-09-22: \"Bash really should be gotten from nix: macOS bash is old\nand deprecated, or just use zsh for scripts. You can use a nix shebang line\n(nix-shell manpage has details here).\"\n\nWHY, MEASURED RATHER THAN ASSERTED. improvise-xgra (PR #133) shipped two bugs that\nwere invisible on the machine that wrote them:\n - `local n=\"$1\" name=\"$2\" f=\"...$n...\"` is NOT left-to-right on bash 5 -- every\n name is created before the assignments run, so $n is unbound under set -u.\n - macOS ships bash 3.2 (2007, GPLv2, frozen), where it works.\nRESULT: scripts/pr-reviews-test.sh passed 19/19 LOCALLY AND 2/19 IN CI ON THE SAME\nCOMMIT. Both bugs surfaced as exit 2 with no output, because a dead command\nsubstitution hands jq an empty --argjson and jq answers with a USAGE error.\n\nTHE INVENTORY, as of 22ab58f: 16 scripts, with 11 `#!/usr/bin/env bash`,\n4 `#!/bin/sh` and 1 `#!/usr/bin/env sh`. So the bash/sh split is already\ninconsistent, and the sh ones are running as macOS bash-in-sh-mode locally and\ndash in CI -- a SECOND undetected platform difference of the same kind.\n\nTHE TWO OPTIONS THE USER NAMED:\n a. A NIX SHEBANG, e.g.\n #!/usr/bin/env nix-shell\n #!nix-shell -i bash -p bash\n which pins ONE bash for every developer and CI alike. Cost: nix-shell\n startup on every invocation, which matters for scripts CI runs per job and\n for anything in a tight loop -- MEASURE IT before committing, and say what it\n costs on a cold and a warm store.\n b. ZSH, which macOS ships current and which is the login shell there. No startup\n cost, but it is a different language in the places that bite (word splitting,\n array indexing from 1, `setopt` needed for sh-compatible globbing), so\n PORTING IS NOT A SHEBANG CHANGE -- every script would need reading.\n\nDECIDE ONE AND APPLY IT UNIFORMLY. A per-script choice reintroduces exactly the\ninconsistency above.\n\nCHECK BEFORE CHOOSING, because it may decide it:\n - Does CI have nix on every job that runs a script? The kb job uses\n `nix shell nixpkgs#trealla`, so nix is present there; confirm for build, test\n and browser.\n - Does the git merge driver (scripts/beads-merge-driver.sh) tolerate a\n nix-shell startup? It runs inside git's merge machinery.\n - Does .envrc / direnv already put a nix bash on PATH? If so, `#!/usr/bin/env\n bash` may already be correct INSIDE the dev shell and wrong only outside it --\n which would make the fix \"run scripts through direnv\" rather than a shebang\n change, and would be cheaper than either option.\n\nWHATEVER LANDS, THE GUARD IS THE POINT: the bug this closes was invisible to a\nlocal run, so the fix must be verified by something that RUNS BOTH SHELLS, or by\npinning so there is only one. A checker wired into CI in the same change is the\nminimum -- improvise-xgra's lesson exactly.","notes":"FIXED in PR #137 (merge a9cad15).\n\nTHE ENVIRONMENT, NOT THE INTERPRETER, WAS THE LOAD-BEARING HALF -- and this\nticket, the originating commit, and the agent's own first hypothesis all blamed\nthe interpreter.\n\nDIRENV DOES NOT SOLVE IT, and the reason is the finding. .envrc puts nix bash\n5.3.15 on PATH, which IS CI's semantics. But pkgs.mkShell ALSO EXPORTS ~30\nGENERICALLY-NAMED LOWERCASE VARIABLES THE RUNNER DOES NOT HAVE -- name, out,\noutputs, system, shell, builder, stdenv, phases, patches, buildInputs, doCheck,\nstrictDeps and more. A script reading one by accident under set -u is SILENT IN\nTHE DEV SHELL AND FATAL IN CI.\n\nThat is exactly what happened. pr-reviews' broken third clause read $name, and in\nthe dev shell $name is \"nix-shell-env\". Reproduced at 051f541^, one machine, one\nsitting:\n macOS /bin/bash 3.2 19 passed, 0 failed\n direnv exec . (nix bash 5.3) 19 passed, 0 failed\n the same, with `name` unset 2 passed, 17 failed \u003c- matches CI exactly\nTWO DIFFERENT REASONS FOR GREEN, ONE OF THEM THE CORRECT INTERPRETER.\n\nNote also: on CI it is $name that is unbound, NOT $n -- `fetch` declares `local\nn`, so dynamic scoping keeps n bound. A fix aimed only at the shell would have\nshipped.\n\nVERIFIED BY THE ORCHESTRATOR, and my first check was confounded: `nix shell\nnixpkgs#bash` appeared to set `name` too, until I re-ran with `env -u name` and\nit came back UNSET while `direnv exec .` still gave nix-shell-env. My check had\ninherited the variable from my own direnv-loaded shell -- it answered \"does name\nend up set\" where the question is \"does nix shell set it\". The agent's control\nwas right and mine was not.\n\nTHE NIX SHEBANG WAS DECLINED ON AVAILABILITY, NOT SPEED. Measured warm, 20\ninvocations: ambient `bash -c true` 5.1ms; `nix shell nixpkgs#bash --command`\n61.8ms; `nix-shell -p bash --run` (THE MANPAGE FORM) 675.2ms, 132x. 675ms inside\ngit's merge machinery for beads-merge-driver.sh is bad -- but the decider is that\nNIX IS ON ONLY THE kb JOB. build, test, wasm, js, harness-lint, browser and\npages-deploy have none, so a nix shebang needs install-nix-action added to three\nmore jobs, paid every run, to reach a bash those runners already ship. zsh\ndeclined too: not a shebang change, sixteen scripts to read, and no zsh on the\nrunner either.\n\nWHAT LANDED: scripts/checkers.sh, the ONE entry point for every check_the_checker,\ncalled verbatim by CI and by the local gate. It pins a bash \u003e= 5 (ambient first,\nnix only when the ambient one is older, exit 3 when neither), SCRUBS THE\nENVIRONMENT TO AN ALLOWLIST, and refuses any shebang in scripts/ that is not\n#!/usr/bin/env bash. CI logs `checkers: bash 5.2 at /usr/bin/bash` -- ambient, no\nnix, zero startup.\n\nTHE INJECTED FAULT, which is the proof: restoring the multi-assignment `local`\ngives 22 passed under the OLD local gate, 22 passed under direnv, and\n2 passed / 20 failed / exit 1 under checkers.sh.\n\nTHE OLD GATE WAS ITSELF THE BUG. `for t in scripts/*-test.sh; do sh \"$t\"; done`\nOVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash\n3.2 in sh-mode regardless of what the file said. The gate in my brief was the\ndisease. Seven other sites ran a repo script through an explicit `sh`, and one of\nthem is dist-workspace.toml -- which a first-line shebang gate STRUCTURALLY CANNOT\nSEE. The shebang rule closes declarations, never invocations.\n\nUNRUN, STATED: scripts/build-web-release.sh runs only under dist in\nbuild-global-artifacts, which no branch push exercises, so its new shebang and two\nchanged lines are mechanical and untested.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T22:31:17Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:23Z","closed_at":"2026-09-22T23:16:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-clso","title":"Ctrl-S saves and also opens the browser's own save dialog","description":"Found by the improvise-jz6w agent (PR #134) as a consequence of its own fix, and\ncorrectly NOT fixed there because every available fix crosses a doctrine line.\n\nNOT A REGRESSION -- verified by the orchestrator. web/app.js:786 calls\npreventDefault only when `!e.ctrlKey \u0026\u0026 !e.altKey`, with the deliberate comment\n\"Ctrl/Alt combinations are left alone: they are the browser's\". So Ctrl-S ALWAYS\nopened Chrome's \"Save page as\" dialog; before improvise-jz6w it did that and\nnothing else. Now it saves the document AND opens the dialog. The save is\ncorrect; the dialog is the wart, and it is newly VISIBLE rather than newly\nintroduced.\n\nWHY IT WAS NOT FIXED IN #134, and this is the useful part:\n - Parsing the frame in app.js gives the page PROTOCOL KNOWLEDGE\n (design-principles section 5).\n - A `e.key === \"s\" \u0026\u0026 e.ctrlKey` test puts a KEYBINDING FACT in the page.\n - Widening the wasm surface so Rust can say \"this key is claimed exclusively\"\n is a new decision the ticket did not authorise.\n\nTHE HONEST SHAPE: the DECISION -- \"is this key exclusively claimed by the app?\" --\nbelongs in Rust as a third thing a resolver already knows, beside Local and\nRequest. That is a natural follow-up to the KeyOutcome variant #134 added, not a\npatch to app.js.\n\nWHATEVER LANDS, THE ASSERTION GOES IN harness/tests/shell.spec.js (gotcha 48: a\nchange to web/*.js belongs in that spec directory). Note harness/node_modules does\nnot exist in a fresh worktree, so `npx playwright test` fails with an error about\nthe PACKAGE rather than the harness -- run npm install in harness/ first.\n\nCHECK BEFORE BUILDING: whether any other Ctrl chord is now in the same position.\nThe preventDefault rule is blanket, so the answer is \"every Ctrl binding the\nresolver claims\", and today that is one.","notes":"SECOND INSTANCE, AND IT IS WORSE: Ctrl-W. Found by the improvise-htl6 agent while hunting for a free key; verified by the orchestrator at keymap.rs:611 --\n\n normal.bind_args(KeyCode::Char('w'), ctrl, \"enter-mode\", vec![\"save-prompt\".into()])\n\nwith the comment \"Ctrl-S saves to the known path; Ctrl-W asks for one\" (improvise-lae).\n\nIN A BROWSER, Ctrl-W CLOSES THE TAB. Same mechanism as the Ctrl-S wart -- web/app.js calls preventDefault only when !e.ctrlKey \u0026\u0026 !e.altKey -- but the consequence is not a spurious dialog the user dismisses, it is THE DOCUMENT'S TAB CLOSING. Whatever the save prompt does is invisible, because the surface it would draw on is gone.\n\nTHE GENERALISATION, which is why this belongs here rather than in its own ticket: THE KEYMAP IS SHARED BETWEEN THE TWO FRONT ENDS (improvise-bpq2, gotcha 47), so a Ctrl- chord chosen for a terminal is one the browser must live with, and the browser has a reserved set the terminal does not. Ctrl-R (reload), Ctrl-S (save page), Ctrl-W (close tab), Ctrl-T, Ctrl-N are all browser-owned. So the fix for this ticket should be stated over the CLASS -- what happens to a browser-reserved chord in a shared keymap -- rather than per key, or a third instance arrives the next time somebody needs a free Ctrl- binding.\n\nNote the constraint that makes this hard is already recorded above: every available fix crosses a doctrine line, because preventDefault-ing selectively needs the page to know which chords the app binds, which is protocol knowledge (design-principles section 5). The shared keymap is where that knowledge already lives, which is a hint at where the answer is.\n\nALSO WORTH RECORDING: Ctrl-R is NOT bound today, and the htl6 agent deliberately did not take it for view-forward for exactly this reason -- it chose ',' instead. So this ticket has already changed one decision.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T20:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:17Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -102,16 +105,16 @@ {"_type":"issue","id":"improvise-k844","title":"importance_ranking overflows to inf for a large importance: 100.0 * imp / total multiplies first","description":"improvise-zv8t forces every document category to `Forced::Categorical`, which is\nright (a `Year` category with items 2024/2025 is a coordinate, not a number).\nThe cost: a WIDE numeric category one-hots past `MAX_FEATURES` and refuses the\nwhole fit, where before it encoded as one numeric feature and a fit that never\nsplit on it succeeded.\n\nMeasured on 100 records with a 100-item numeric `Year` beside a `Cost` that\npredicts `Score` on its own:\n- pre-zv8t: fit lands on `IF(Cost \u003c 48.015...)`; `Year` never appears.\n- post-zv8t: preflight refuses at 101 features.\n\nPinned by `a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it`\nin crates/improvise-gp/src/fit.rs, and argued in context/code/gp.md section 11.\n\nThe workaround the refusal names works: mark the category a Label and the\npre-zv8t feature set is restored exactly. What is lost is the case where the\nuser wants neither -- a wide numeric category left alone while the fit uses the\nmeasures.\n\n## Steps to Reproduce\n\nBuild a model with a Regular category `Year` holding 100 numeric items\n(1900..1999), a Label category for record identity, and two measures `Cost` and\n`Score` where Score = 4000 if Cost \u003e 50 else 1000. Put `Year` on Axis::Row and\n_Measure on Axis::Page. Run `fit_measure(.., \"Score\", ..)`.\n\nPre-zv8t it lands on `IF(Cost \u003c 48.015..)`. Post-zv8t it is refused with\n\"would encode 101 features from 100 records\". The fixture is\n`wide_category_beside_a_good_measure()` in crates/improvise-gp/src/fit.rs.\n\n## Acceptance Criteria\n\nThe honest fix is a budget that can decline A COLUMN rather than the whole\ntable: `encoded_width` (and the exact check beside it) would drop the widest\ncategory and report it as a `Dataset::warnings` notice -- gotcha 37(a), it comes\nback as DATA and nothing prints. That keeps the zv8t decision intact (a category\nis still never inferred numeric) and changes only what the budget does when it\ncannot afford one.\n\nDo NOT close this by making the forcing conditional on cardinality: that is\ninference by another name, and makes the ENCODING depend on a budget rather than\non what the document says.\n","notes":"=== TRACKER DEFECT, RECORDED BEFORE THE CLOSE ===\nTHIS TICKET'S STORED DESCRIPTION IS VERBATIM improvise-e8no'S BODY (the wide-\nnumeric-category budget) and contradicts its own title. Confirmed by the\norchestrator with a direct diff of the two DESCRIPTION blocks: IDENTICAL. The\nagent worked the TITLE, which was the right call. Everything below is the actual\nticket.\n\n=== FIXED in PR #139 (merge 1ae2d2e) ===\n\nREPRODUCED at HEAD, end to end: fit_measure returned [(\"Cost\", inf)] -- a user's\nfit report saying one feature carries inf%.\n\nWHERE IT IS BORN: gp_tree::fit::importance_ranking, `100.0 * imp[j] / total` in\nf32, which associates LEFT. TWO DISTINCT OVERFLOWS, and this ticket names only the\nfirst:\n 1. THE MULTIPLY. A lone importance above f32::MAX/100 ~ 3.4e36 makes\n 100.0 * 3e37 = inf before the divide that would have returned it to range.\n 2. THE SUM -- THE SILENT TWIN, and it is real. Two importances at 2e38 are each\n well inside f32, but their f32 TOTAL is inf, so every share came back NaN.\n No panic, no refusal, just NaN% in the report. Exactly cv_regress's shape\n from improvise-pv3g.\n\nREACHABLE PAST pv3g, traced rather than guessed: both callers score importance in\nthe document's OWN units (fit_csv -\u003e real_unit_mse; fit_measure -\u003e mse over\nUNNORMALIZED rows), so an importance is an MSE difference and grows as the\ntarget's SQUARE. That leaves a live window between pv3g's standardize refusal\n(spread ~5e18 at 12 rows) and this overflow (~1.8e18). As with pv3g, every INPUT\nwas finite and the overflow was in the DERIVED quantity.\n\nTHE FIX makes the arithmetic incapable of it rather than checking afterwards: sum\nand ratio in f64, narrowing only the share, which is bounded by 100 and therefore\nalways finite. One guard remains for an importance that ARRIVES non-finite -- note\nNaN \u003c= 0.0 is false, so the pre-existing early return passed NaN straight through.\n\nTHE RIVAL IS THIS TICKET'S OWN TITLE. Reassociating to 100.0 * (imp[j] / total)\nin f32 -- \"multiplies first\", so divide first -- passes the lone-importance test\nAND the shares-sum-to-100 test, and FAILS the twin and the non-finite guard:\n58 passed, 2 failed. It converts NaN% into a serene 0% FOR EVERY FEATURE, which is\nworse for being readable. THE AC AS WRITTEN WOULD HAVE SHIPPED A HALF-FIX.\n\nAND THE REASON IT SAT OPEN IS THE BEST PART. context/code/gp.md section 7 --\npv3g's own write-up -- did not merely mention this bug, it DEFERRED IT ON A COST\nTHAT IS FALSE: \"reordering it moves the bit patterns\nmisclass_rate_and_importance_are_bit_for_bit_what_they_were pins\". That test pins\npermutation_importance's RAW SCORES, which importance_ranking consumes afterwards.\nVERIFIED BY THE ORCHESTRATOR: scoring_known_answers.rs is absent from the diff\nentirely -- byte-identical across the fix. A cost predicted from a test's NAME\nrather than from what it CALLS is how a one-line fix stays filed, and it reads as\ndiligence, which is why it survives review. Corrected in place rather than\ndeleted.\n\nThe tell was visible all along: pv3g's own\nthe_cli_does_not_abort_when_an_importance_share_overflows says IN ITS NAME that a\nshare overflows at 1e18. The clamp stopped the abort; the inf% it was clamping\nnever stopped.\n\nNOT VERIFIED: the f64 round-trip is not bit-pinned for the ORDINARY range.\nimportance_ranking's shares have never been pinned and this did not start; a \u003c=1\nulp shift in a printed {:\u003e5.1}% is possible in principle and nothing would catch\nit. A share-level bit pin is a reasonable follow-up.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:33:29Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:11:18Z","closed_at":"2026-09-22T23:11:18Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4h8a","title":"Six of AppSnapshot's eight restored fields are unguarded; the rival fix passes the suite","description":"Found by the wave's adversarial reviewer at PR #109 head d4d1cd4, by injecting\nthe RIVAL FIX rather than only the bug.\n\nimprovise-yqpd's clause-4 work grew DocumentSnapshot into AppSnapshot, which now\nrestores eight fields. The evidence says \"all six now round-trip through\nAppSnapshot\". Only TWO are pinned by any test.\n\nTHE RIVAL FIX THAT PASSES: replace `self.view_state = snapshot.view_state` with\nrestoration of ONLY view_state.active_view and view_state.status_msg -- the two\nfields the test asserts -- and the ENTIRE workspace suite passes (1585, 0\nfailed).\n\nPushing further, each of these can be removed from App::restore with the whole\nsuite still green:\n cursor_epoch\n abort_effects\n last_reshaped_views\n last_reframed_views\n written_view_frames \u003c- new behaviour with NO test at all\n last_reframed_elsewhere (reverted to the old .clear() shape)\n\nNOT A MERGE BLOCKER, and the current code is the SAFE direction: over-restoring\ncosts nothing, and gotcha 35's asymmetry applies -- over-reporting pays a\nredundant projection, under-reporting leaves a client wrong forever.\n\nTHE HAZARD IS THE NEXT READER. A later \"simplification\" of App::restore back\ntoward the two asserted fields would regress clause 4 for cursors, mode,\nbuffers, panels and search SILENTLY, with a green suite. That is exactly this\nrepo's characteristic defect, and the evidence file currently invites it by\nsaying six fields round-trip without saying four of them are unguarded.\n\nWHAT TO DO: pin the unguarded fields, or state in the evidence that they are\nunguarded and why over-restoring is deliberate. The test to reach for is a\nrejected frame that moved a cursor and a mode, asserting both are back --\nwhich is the user-visible half nothing currently covers.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:30Z","labels":["adr-001","protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mszy","title":"PR 105's evidence claims one test catches the arm swap; sixteen do","description":"Found by the wave's adversarial reviewer of record at PR #105 head 4e08a9b.\n\ncontext/test-evidence/formula-and-io.md and the commit message both claim that\nswapping the `string` and `pipe_quoted` arms in build_primary \"fails\neach_primary_alternative_builds_its_own_expr, and nothing else in the 85-test\ncrate suite does.\"\n\nNOT REPRODUCIBLE. The reviewer performed exactly that swap and measured:\n - 16 failures in improvise-formula: the new guard plus 12 other\n parser::tests::* (parse_where_clause, parse_quoted_string_in_where,\n pipe_quoted_in_where_filter_value, where_keyword_is_case_insensitive, ...)\n and 3 grammar_prop_tests::*\n - PLUS 3 more in improvise-core (formula_where_applied_to_matching_region and\n siblings)\n\nMost fail as debug_assert! PANICS in strip_string_quotes/unquote_pipe at\nparser.rs:352/:359, not as assertion failures. The \"85-test crate suite\" figure\nitself is correct.\n\nHOW THE AUTHOR COULD HONESTLY HAVE SEEN A SMALL FAILURE SET: under that\ninjection `cargo test --workspace` HALTS at improvise-core and never reaches\nimprovise-formula. So a casual measurement sees the core failures only.\n\nTHE CODE IS RIGHT AND THE TEST IS HARMLESS. What is wrong is the EVIDENCE: the\nguard is presented as load-bearing for a fault that 15 pre-existing tests already\ncatch. In this repo a false injection claim in a checked-in evidence file is\nworse than no claim, because the next reader trusts it and stops looking.\n\nTWO ACCEPTABLE RESOLUTIONS, either fine:\n - find a fault that ONLY each_primary_alternative_builds_its_own_expr catches\n (a transposition among arms whose bodies do not debug_assert would be the\n place to look), or\n - state plainly that the guard is belt-and-braces over existing coverage.\n\nWhat is not acceptable is leaving the claim as written.\n\nNote what the reviewer DID confirm about this PR, so this bead is not read as\nundermining it: injecting a non-atomic probe rule into formula.pest produces\nFOUR E0004 non-exhaustive-pattern errors, one per dispatch site. A new grammar\nrule genuinely cannot land silently. That is the PR's real value and it holds.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:29Z","created_by":"Edward Langley","updated_at":"2026-09-21T23:03:29Z","labels":["docs","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-b4ks","title":"The documented opfs-contract fmt gate exits 1 as a usage error, indistinguishable from a real failure","description":"The recipe circulated in six wave briefs and in an orchestrator message reads:\n\n cargo fmt -p opfs-contract -- --check\n\nFrom the workspace root this exits **1** with:\n\n package `opfs-contract` is not a member of the workspace\n This utility formats all bin and lib files of the current crate using rustfmt.\n\nThat is a USAGE error carrying the SAME EXIT CODE as a formatting failure. A\ncaller checking `$?` cannot tell \"this crate is misformatted\" from \"you spelled\nthe command wrong and nothing was checked\". Verified by the orchestrator.\n\nThe spelling that reaches the second workspace root:\n\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check # exit 0\n\nWHY THIS MATTERS MORE THAN A TYPO: an earlier agent reported this gate as\n\"clean\" during the wave's integration review. Either it used a different\nspelling and did not say so, or it read exit 1 as a pass -- and neither is\ndistinguishable from the report as written. A gate whose failure mode looks\nexactly like the thing it checks for will eventually be \"fixed\" by deleting it,\nand in the meantime it launders a non-run into a pass.\n\nThis is the same family as the pipe-into-tail trap that scripts/pr-reviews\ncarries a warning about: `$?` belonging to something other than the thing you\nmeant to test.\n\nWHERE THE RECIPE NEEDS CORRECTING: wherever the harness gate is written down --\nAGENTS.md's gate list, the wave brief template, and any CI step that spells it\nthis way. CI itself uses `cargo fmt -p opfs-contract` from INSIDE\nharness/opfs-contract, where it resolves, which is why CI has been right while\nevery local invocation of the documented form was not.\n\nCONTEXT: harness/opfs-contract carries its own [workspace] table, so\n`cargo fmt --all` does not reach it. Exactly two workspace roots exist --\n`grep -l '^\\[workspace\\]' $(find . -name Cargo.toml -not -path '*/target/*')` --\nso one extra gate is complete, not the first of an unknown number.\n","notes":"SHARPENED 2026-09-21: the boundary is POSITIONAL, and that changes who is at fault.\n\n cargo fmt -p opfs-contract -- --check from the REPO ROOT -\u003e exit 1, 'not a member of the workspace', --check never runs\n cargo fmt -p opfs-contract -- --check from INSIDE the crate -\u003e exit 0, works\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check from the root -\u003e exit 0, works\n\nSo CI AS WRITTEN IS CORRECT -- its working-directory: is exactly what makes the -p form resolve. The hazard is a BRIEF or a local pre-push habit telling someone to run it from the root, which is what the orchestrator did in six wave briefs.\n\nThe failure mode is worse than 'wrong spelling' in both directions: from the root the gate reports FAILURE when the crate is clean, and it cannot report SUCCESS when the crate is dirty. It is decoupled from the thing it checks entirely, while wearing the exit code of a real formatting failure.\n\nThis also explains a discrepancy in the wave's records: one agent reported this gate 'clean' and the orchestrator later measured exit 1. Both were telling the truth -- that agent had cd'd into the crate directory and never saw the error. A gate whose result depends on the working directory, with no indication in the output which one it ran in, cannot be audited from a report.\n\nFIX: correct the recipe wherever it is written for humans or agents (AGENTS.md gate list, the wave brief template). Prefer the --manifest-path form, which is position-independent and therefore says what it means. Leave CI alone.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-ci","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:49:17Z","started_at":"2026-09-23T02:49:17Z","lease_expires_at":"2026-09-23T02:54:17Z","heartbeat_at":"2026-09-23T02:49:17Z","labels":["ci","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b4ks","title":"The documented opfs-contract fmt gate exits 1 as a usage error, indistinguishable from a real failure","description":"The recipe circulated in six wave briefs and in an orchestrator message reads:\n\n cargo fmt -p opfs-contract -- --check\n\nFrom the workspace root this exits **1** with:\n\n package `opfs-contract` is not a member of the workspace\n This utility formats all bin and lib files of the current crate using rustfmt.\n\nThat is a USAGE error carrying the SAME EXIT CODE as a formatting failure. A\ncaller checking `$?` cannot tell \"this crate is misformatted\" from \"you spelled\nthe command wrong and nothing was checked\". Verified by the orchestrator.\n\nThe spelling that reaches the second workspace root:\n\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check # exit 0\n\nWHY THIS MATTERS MORE THAN A TYPO: an earlier agent reported this gate as\n\"clean\" during the wave's integration review. Either it used a different\nspelling and did not say so, or it read exit 1 as a pass -- and neither is\ndistinguishable from the report as written. A gate whose failure mode looks\nexactly like the thing it checks for will eventually be \"fixed\" by deleting it,\nand in the meantime it launders a non-run into a pass.\n\nThis is the same family as the pipe-into-tail trap that scripts/pr-reviews\ncarries a warning about: `$?` belonging to something other than the thing you\nmeant to test.\n\nWHERE THE RECIPE NEEDS CORRECTING: wherever the harness gate is written down --\nAGENTS.md's gate list, the wave brief template, and any CI step that spells it\nthis way. CI itself uses `cargo fmt -p opfs-contract` from INSIDE\nharness/opfs-contract, where it resolves, which is why CI has been right while\nevery local invocation of the documented form was not.\n\nCONTEXT: harness/opfs-contract carries its own [workspace] table, so\n`cargo fmt --all` does not reach it. Exactly two workspace roots exist --\n`grep -l '^\\[workspace\\]' $(find . -name Cargo.toml -not -path '*/target/*')` --\nso one extra gate is complete, not the first of an unknown number.\n","notes":"SHARPENED 2026-09-21: the boundary is POSITIONAL, and that changes who is at fault.\n\n cargo fmt -p opfs-contract -- --check from the REPO ROOT -\u003e exit 1, 'not a member of the workspace', --check never runs\n cargo fmt -p opfs-contract -- --check from INSIDE the crate -\u003e exit 0, works\n cargo fmt --manifest-path harness/opfs-contract/Cargo.toml -- --check from the root -\u003e exit 0, works\n\nSo CI AS WRITTEN IS CORRECT -- its working-directory: is exactly what makes the -p form resolve. The hazard is a BRIEF or a local pre-push habit telling someone to run it from the root, which is what the orchestrator did in six wave briefs.\n\nThe failure mode is worse than 'wrong spelling' in both directions: from the root the gate reports FAILURE when the crate is clean, and it cannot report SUCCESS when the crate is dirty. It is decoupled from the thing it checks entirely, while wearing the exit code of a real formatting failure.\n\nThis also explains a discrepancy in the wave's records: one agent reported this gate 'clean' and the orchestrator later measured exit 1. Both were telling the truth -- that agent had cd'd into the crate directory and never saw the error. A gate whose result depends on the working directory, with no indication in the output which one it ran in, cannot be audited from a report.\n\nFIX: correct the recipe wherever it is written for humans or agents (AGENTS.md gate list, the wave brief template). Prefer the --manifest-path form, which is position-independent and therefore says what it means. Leave CI alone.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ci","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:40Z","started_at":"2026-09-23T02:49:17Z","closed_at":"2026-09-23T03:29:40Z","close_reason":"Merged in PR #159. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["ci","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bok4","title":"A failing effect narrates instead of reporting, so a frame containing it is accepted","description":"Found by the wave's integration/review agent auditing PR #109, and confirmed and\nscoped by that PR's author. ADR-001 clause 4 now rolls back the document, the\nmirrors and the session slice -- but the partial-and-silent SHAPE it exists to\nkill survives one layer down, for every command whose target is not a record.\n\nMECHANISM: reduce::broken_chain asks records_named, which names only SetCell and\nClearCell. Everything else is unguarded. Separately, an effect that FAILS writes\na sentence to view_state.status_msg and does NOT set abort_effects, so the frame\ncontaining it is reported ACCEPTED.\n\nMEASURED AS ACCEPTED, both by the reviewer:\n\n [DeleteView(\"Scratch\"), SetNumberFormat { view: \"Scratch\" }]\n [RemoveCategory(\"Region\"), AddItem { category: \"Region\" }]\n\nThe second is the sharper one: it writes its error to the HOST's status line and\nnever tells the client anything at all. A connected browser sees a frame it\ncomposed reported accepted, with one of its commands having done nothing, and no\nchannel carrying the reason.\n\nWHY THIS IS NOT PART OF improvise-yqpd: closing the class needs effects to\nREPORT failure rather than narrate it -- roughly fifty of them -- which is a\ndifferent change from the snapshot-and-restore clause 4 landed. yqpd closed the\nrecord-addressed INSTANCE. That scope is stated in PR #109's body under \"what\nthis PR does NOT close\" rather than left implied.\n\nTHE SHAPE OF A FIX, not yet decided: an Effect currently returns nothing, so\nfailure has nowhere to go except status_msg. Either effects gain a fallible\nresult that apply_effects can turn into an abort, or the per-command check grows\nbeyond records_named to ask \"does this command's target still resolve\" for the\nview-, category- and item-addressed variants too. The first is larger and\ncloses the class; the second is narrower and closes the reachable cases.\n\nNote the interaction with gotcha 21: an abort is not a rollback, and tidy-up\ngoes at the HEAD of a batch where an abort cannot reach it. Any change here has\nto keep that property, because it is what stops a failed save stranding the user\nin a mode whose minibuffer hides the error.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:49:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:49:20Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-hkbc","title":"Guard the position-free ModelCommand vocabulary, which 6nsd's answer silently depends on","description":"improvise-6nsd was closed as \"not a hole, conditionally\": a stale layout tier\ncannot misdirect a mutation BECAUSE no ModelCommand variant carries a row or\ncolumn index. Verified independently by two agents over all 24 variants.\n\nNothing guards it. A future positional variant reopens the hole SILENTLY, and\nlayout_version under-reports here by construction -- gotcha 35's expensive\ndirection, where under-reporting leaves a client wrong forever.\n\nThe guard: an exhaustive match over ModelCommand in improvise-protocol asserting\nevery variant names its target BY NAME (category, item, group, view, axis,\nformat, path) or by a position-free surrogate (RecordCell/RecordHash). Adding a\npositional variant then fails the build and forces the ADR conversation rather\nthan inheriting an answer that no longer holds.\n\nShape it like reduce.rs::records_named, which is already an exhaustive match\nwith an or-pattern for the variants that name no record -- same file, same\ndiscipline, and the two can be read together.\n\nNOT covered by this guard, and stated so nobody thinks it is: position-free ON\nTHE WIRE is not position-free END TO END. improvise-2wu3 is the live\ncounterexample -- web/app.js captures viewport (row, col) at paint time and\nresolves it against the current cache at commit. That is a client bug the\nprotocol's discipline makes possible to have, not one this test can catch.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:19Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-k84e","title":"Clause 4 rollback of a frame containing LoadModel: the rebase-then-undo case is untested","description":"ADR-001 clause 4 landed in improvise-yqpd: Host::commands snapshots the document\nAND clones the whole Sessions before a frame, restoring both on failure.\n\nUNTESTED, and the agent that built it named this as the residual it would most\nwant reviewed: a failing frame that CONTAINS a document-replacing command.\n\nThe sequence: LoadModel -\u003e Applied::Rebased -\u003e every mirror reseeded and fresh\nSnapshots owed -\u003e then a later command in the same frame fails -\u003e undo_frame\nrestores the document and the Sessions clone.\n\nIt SHOULD be sound, because the Sessions clone restores ClientSession::generation\nalong with everything else, so the reseed is undone with it. But no fixture\nexercises it and \"should be sound\" is the phrasing that precedes every entry in\nthis repo's gotcha list.\n\nNote the fixture warning from gotcha 39 applies with force here: when testing a\ndocument swap, the incoming document's active view must have the SAME NAME as\nthe outgoing one, or apply_model broadcasts a SwitchView, the client folds it,\nand the stale cursor is read under a key nothing has written -- right by\naccident, with the bug invisible. Use a_document_shaped_differently_under_the_same_view_name.\n","notes":"PARTLY ANSWERED by PR #109's fix round (2026-09-21), scope narrowed.\n\nWHAT IS NOW DONE: the ghost active_view this bead's sibling finding named is FIXED, and a_rolled_back_load_leaves_no_ghost_view_and_no_false_status covers the rolled-back LoadModel. The fixture was renamed so it cannot pass vacuously -- with both documents named Default the ghost is indistinguishable from a correct value, and the test failed left: \"Loaded\", right: \"Default\" before the fix.\n\nChecked before renaming, and worth recording: gotcha 39 requires a SAME-NAME fixture for the stale-cursor case, but that is a DIFFERENT fixture (a_document_shaped_differently_under_the_same_view_name in project.rs). Neither user of host_over_a_loadable_document depends on the names matching, so gotcha 39's guard is untouched.\n\nWHAT REMAINS, and this bead should stay open scoped to it: the Applied::Rebased path proper -- a frame whose LoadModel SUCCEEDS, emits Snapshots, and is THEN rolled back by a later command. The Sessions clone restores ClientSession::generation so it should be sound, but the Snapshots were already computed and discarded, and nothing tests that.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:05:43Z","started_at":"2026-09-23T03:05:43Z","lease_expires_at":"2026-09-23T03:10:43Z","heartbeat_at":"2026-09-23T03:05:43Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hkbc","title":"Guard the position-free ModelCommand vocabulary, which 6nsd's answer silently depends on","description":"improvise-6nsd was closed as \"not a hole, conditionally\": a stale layout tier\ncannot misdirect a mutation BECAUSE no ModelCommand variant carries a row or\ncolumn index. Verified independently by two agents over all 24 variants.\n\nNothing guards it. A future positional variant reopens the hole SILENTLY, and\nlayout_version under-reports here by construction -- gotcha 35's expensive\ndirection, where under-reporting leaves a client wrong forever.\n\nThe guard: an exhaustive match over ModelCommand in improvise-protocol asserting\nevery variant names its target BY NAME (category, item, group, view, axis,\nformat, path) or by a position-free surrogate (RecordCell/RecordHash). Adding a\npositional variant then fails the build and forces the ADR conversation rather\nthan inheriting an answer that no longer holds.\n\nShape it like reduce.rs::records_named, which is already an exhaustive match\nwith an or-pattern for the variants that name no record -- same file, same\ndiscipline, and the two can be read together.\n\nNOT covered by this guard, and stated so nobody thinks it is: position-free ON\nTHE WIRE is not position-free END TO END. improvise-2wu3 is the live\ncounterexample -- web/app.js captures viewport (row, col) at paint time and\nresolves it against the current cache at commit. That is a client bug the\nprotocol's discipline makes possible to have, not one this test can catch.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:33:57Z","started_at":"2026-09-23T03:33:57Z","lease_expires_at":"2026-09-23T03:38:57Z","heartbeat_at":"2026-09-23T03:33:57Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-k84e","title":"Clause 4 rollback of a frame containing LoadModel: the rebase-then-undo case is untested","description":"ADR-001 clause 4 landed in improvise-yqpd: Host::commands snapshots the document\nAND clones the whole Sessions before a frame, restoring both on failure.\n\nUNTESTED, and the agent that built it named this as the residual it would most\nwant reviewed: a failing frame that CONTAINS a document-replacing command.\n\nThe sequence: LoadModel -\u003e Applied::Rebased -\u003e every mirror reseeded and fresh\nSnapshots owed -\u003e then a later command in the same frame fails -\u003e undo_frame\nrestores the document and the Sessions clone.\n\nIt SHOULD be sound, because the Sessions clone restores ClientSession::generation\nalong with everything else, so the reseed is undone with it. But no fixture\nexercises it and \"should be sound\" is the phrasing that precedes every entry in\nthis repo's gotcha list.\n\nNote the fixture warning from gotcha 39 applies with force here: when testing a\ndocument swap, the incoming document's active view must have the SAME NAME as\nthe outgoing one, or apply_model broadcasts a SwitchView, the client folds it,\nand the stale cursor is read under a key nothing has written -- right by\naccident, with the bug invisible. Use a_document_shaped_differently_under_the_same_view_name.\n","notes":"PARTLY ANSWERED by PR #109's fix round (2026-09-21), scope narrowed.\n\nWHAT IS NOW DONE: the ghost active_view this bead's sibling finding named is FIXED, and a_rolled_back_load_leaves_no_ghost_view_and_no_false_status covers the rolled-back LoadModel. The fixture was renamed so it cannot pass vacuously -- with both documents named Default the ghost is indistinguishable from a correct value, and the test failed left: \"Loaded\", right: \"Default\" before the fix.\n\nChecked before renaming, and worth recording: gotcha 39 requires a SAME-NAME fixture for the stale-cursor case, but that is a DIFFERENT fixture (a_document_shaped_differently_under_the_same_view_name in project.rs). Neither user of host_over_a_loadable_document depends on the names matching, so gotcha 39's guard is untouched.\n\nWHAT REMAINS, and this bead should stay open scoped to it: the Applied::Rebased path proper -- a frame whose LoadModel SUCCEEDS, emits Snapshots, and is THEN rolled back by a later command. The Sessions clone restores ClientSession::generation so it should be sound, but the Snapshots were already computed and discarded, and nothing tests that.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:40Z","started_at":"2026-09-23T03:05:43Z","closed_at":"2026-09-23T03:29:40Z","close_reason":"Merged in PR #163. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lm5e","title":"[test] Debug-string tests verify effect names, not correctness","description":"## Summary\n\nUmbrella: many command tests assert `dbg.contains(\"EffectName\")` on debug-formatted effect lists. That checks type-name presence, not field values, correct application, or shadowing by later effects.\n\n## Steps to Reproduce\n\n1. Change an effect's fields to incorrect values while keeping the type name.\n2. `dbg.contains(\"AddCategory\")`-style tests still pass.\n\n## Acceptance Criteria\n\n- High-value tests assert structured effect fields / post-fold state, not only Debug substrings; remaining Debug checks documented as smoke-only.\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\nMultiple files under `crates/improvise-command/src/command/cmd/`\n\n## PR / Attribution\n\nPR #100 F20 umbrella. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.\nPR #148 closes the FIELD-CARRYING-EFFECT SLICE of this umbrella and says so; roughly 60 Debug-substring assertions remain outside the 16, including 9 in cmd/grid.rs. Evidence the step was run rather than argued: one patch made 14 command sites emit the right effect TYPE with a WRONG CONSTANT FIELD. Under the old assertions that was 525 passed / 6 failed; under the new ones 510 / 21. So 14 of the 16 survived the rival, and of the 6 that did fail only one was in the list -- the other five were in ui/app.rs and ui/reduce.rs, meaning THE COMMAND LAYER NEVER CHECKED A FIELD AT ALL. Assertions on FIELDLESS effects (Quit, Save, RebuildLayout, TransposeAxes, AbortChain, TogglePruneEmpty) were deliberately left alone: there the type name IS the whole claim, so naming it is complete rather than smoke.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:17:29Z","started_at":"2026-09-23T01:25:32Z","lease_expires_at":"2026-09-23T01:30:32Z","heartbeat_at":"2026-09-23T01:25:32Z","external_ref":"gh-pr-100-F20-debug","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gjat","title":"[test] sequence-associativity test proves a mathematical tautology","description":"## Summary\n\n`law_sequence_associativity` concatenates effect Vecs as `(a+b)+c` vs `a+(b+c)`. List concatenation is trivially associative — the test cannot fail regardless of command behavior (tautology).\n\n## Steps to Reproduce\n\n1. Note the test only compares Vec concatenations of execute results on the same ctx.\n2. It passes for any deterministic executes that return Vecs.\n\n## Acceptance Criteria\n\n- Replace with a property that can fail (ordered fold of effects on state), or remove as non-evidence.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/navigation.rs` ~L388-431\n\n## PR / Attribution\n\nPR #100 F16. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:17:28Z","started_at":"2026-09-23T01:18:57Z","closed_at":"2026-09-23T02:17:28Z","close_reason":"PR #144. law_sequence_associativity asserted effects_debug((a++b)++c) == effects_debug(a++(b++c)) -- the associativity of Vec::extend, and it never applied an effect. Replaced by a test that folds the batch a Binding::Sequence really emits (one flattened effect vec) and asserts the last write wins across four orderings. Rivals: Move returning Vec::new() when the position does not change, and apply_effects folding the batch in reverse -- the old law passed both. NOT PREDICTED, and it is the transferable part: the first replacement applied each command as its OWN apply_effects batch, and the reversed-fold rival DID NOT FIRE, because each single-Move batch reverses to the same cursor. A fold test that batches per step is testing the wrong fold.","external_ref":"gh-pr-100-F16-assoc","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ub4o","title":"[test] toggle-group-involution test doesn't apply effects between calls","description":"## Summary\n\n`law_toggle_group_involution` asserts two `cmd.execute(\u0026ctx)` debug outputs are equal without applying the first effects. `CmdContext` is borrowed/immutable — the test only proves deterministic pure execute, not involution (toggle twice = identity).\n\n## Steps to Reproduce\n\n1. Fault-inject `ToggleGroupAtCursor` to return different effects on odd/even calls without applying between.\n2. Test still passes (same unchanged ctx).\n\n## Acceptance Criteria\n\n- Test applies first effects (or folds state) before second execute; can fail when involution is broken.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/grid.rs` ~L26-36\n\n## PR / Attribution\n\nPR #100 F15. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:18:56Z","started_at":"2026-09-23T02:18:56Z","lease_expires_at":"2026-09-23T02:23:56Z","heartbeat_at":"2026-09-23T02:18:56Z","external_ref":"gh-pr-100-F15-involution","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ub4o","title":"[test] toggle-group-involution test doesn't apply effects between calls","description":"## Summary\n\n`law_toggle_group_involution` asserts two `cmd.execute(\u0026ctx)` debug outputs are equal without applying the first effects. `CmdContext` is borrowed/immutable — the test only proves deterministic pure execute, not involution (toggle twice = identity).\n\n## Steps to Reproduce\n\n1. Fault-inject `ToggleGroupAtCursor` to return different effects on odd/even calls without applying between.\n2. Test still passes (same unchanged ctx).\n\n## Acceptance Criteria\n\n- Test applies first effects (or folds state) before second execute; can fail when involution is broken.\n\n## Doctrine\n\n`doctrine(fold_tests_must_test_order_not_the_axiom)`\n\n## Location\n\n`crates/improvise-command/src/command/cmd/grid.rs` ~L26-36\n\n## PR / Attribution\n\nPR #100 F15. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-23T03:29:42Z","started_at":"2026-09-23T02:18:56Z","closed_at":"2026-09-23T03:29:42Z","close_reason":"Merged in PR #156. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","external_ref":"gh-pr-100-F15-involution","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y01k","title":"ws-server silently drops ServerFrame/ClientFrame/closed-doc traffic","description":"## Summary\n\nws-server silently loses protocol traffic in three places: (1) `ServerFrame` serde failure dropped with `continue` in writer task; (2) unparseable ClientFrame text ignored with no log; (3) ClientFrame for a closed/unregistered document yields empty response — client gets nothing (no rejection).\n\n## Steps to Reproduce\n\n1. Force ServerFrame that fails `serde_json::to_string` — frame lost, no log.\n2. Send `{\"Unknown\": {}}` text — no response, no log.\n3. Send commands after document close/evict — empty response, client keeps composing.\n\n## Acceptance Criteria\n\n- Serialize/parse failures are logged (and ideally signaled to the peer); closed-doc frames produce an explicit rejection/error frame.\n\n## Location\n\n`crates/improvise-ws-server/src/transport.rs` ~L179-183, L240-248; `registry.rs` `handle` ~L193-197\n\n## PR / Attribution\n\nPR #100 F17+F18+F19 consolidated. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:05Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:59Z","closed_at":"2026-09-21T21:58:59Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nAll three drops logged.","external_ref":"gh-pr-100-F17-19-ws","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r4nf","title":"serde_json failures in wasm exports are silent drops, not errors","description":"## Summary\n\nWasm exports `on_key_event`, `typed_cell`, `clicked` use `.ok()` on `serde_json::to_string()`. Serialization failure returns `None`, which JS treats as \"nothing to send\" (not editable / invalid click) — a valid operation is silently dropped.\n\n## Steps to Reproduce\n\n1. Inject a non-serializable value into the frame.\n2. Observe no error reaches the JS caller; operation vanishes.\n\n## Acceptance Criteria\n\n- Serde failures surface as errors to JS (not conflated with empty/None outcomes).\n\n## Location\n\n`crates/improvise-wasm-client/src/lib.rs` — `on_key_event` ~L54, `typed_cell` ~L97, `clicked` ~L178\n\n## PR / Attribution\n\nPR #100 F13. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:59:00Z","closed_at":"2026-09-21T21:59:00Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nResult\u003cOption\u003cString\u003e, JsError\u003e; Option still means 'nothing to send', a serde failure throws. JS 'if (frame)' unchanged everywhere.\n\nShape-grep confirmed no second site: serde_json::(to_string|from_str)...ok() across crates/ and src/ gives 3 hits, all in wasm-client/lib.rs, all named by this bead. Zero remain.","external_ref":"gh-pr-100-F13-serde","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xg1z","title":"editing mode with missing buffer renders empty overlay instead of cell content","description":"## Summary\n\nIf `is_editing()` is true but no `\"edit\"` buffer exists, `render_model` sets `editing` to `Some(\"\")`. The UI shows an empty editor overlay over cell content.\n\n## Steps to Reproduce\n\n1. Fold `ChangeMode(Editing)` without preceding `SetBuffer{name:\"edit\",...}`.\n2. Call `render_model()` — empty overlay instead of cell content.\n\n## Acceptance Criteria\n\n- Missing edit buffer falls back to cell content (or refuses Editing mode loudly); never blank overlay over real content.\n\n## Location\n\n`crates/improvise-wasm-client/src/render.rs` — `render_model` ~L268-271\n\n## PR / Attribution\n\nPR #100 F14. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-23T03:02:29Z","started_at":"2026-09-23T03:02:29Z","lease_expires_at":"2026-09-23T03:07:29Z","heartbeat_at":"2026-09-23T03:02:29Z","external_ref":"gh-pr-100-F14-edit","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xg1z","title":"editing mode with missing buffer renders empty overlay instead of cell content","description":"## Summary\n\nIf `is_editing()` is true but no `\"edit\"` buffer exists, `render_model` sets `editing` to `Some(\"\")`. The UI shows an empty editor overlay over cell content.\n\n## Steps to Reproduce\n\n1. Fold `ChangeMode(Editing)` without preceding `SetBuffer{name:\"edit\",...}`.\n2. Call `render_model()` — empty overlay instead of cell content.\n\n## Acceptance Criteria\n\n- Missing edit buffer falls back to cell content (or refuses Editing mode loudly); never blank overlay over real content.\n\n## Location\n\n`crates/improvise-wasm-client/src/render.rs` — `render_model` ~L268-271\n\n## PR / Attribution\n\nPR #100 F14. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"box@local","created_at":"2026-09-21T20:49:04Z","created_by":"external-grok-bot","updated_at":"2026-09-23T03:29:41Z","started_at":"2026-09-23T03:02:29Z","closed_at":"2026-09-23T03:29:41Z","close_reason":"Merged in PR #162. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","external_ref":"gh-pr-100-F14-edit","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-sfwy","title":"[test] records_mode_resolve_display asserts non-empty instead of expected value","description":"## Summary\n\nTest `records_mode_resolve_display_returns_values` asserts only `!display.is_empty()` for Region when expected is `\"North\"`. Any non-empty garbage string passes.\n\n## Steps to Reproduce\n\n1. Fault-inject records display for Category to return `\"GARBAGE\"`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected display value (`\"North\"` for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/view/layout.rs` — `records_mode_resolve_display_returns_values` ~L810\n\n## PR / Attribution\n\nPR #100 F11. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"open","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:27:47Z","external_ref":"gh-pr-100-F11-records","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vyrx","title":"eval_bool_cached uses wildcard match on BinOp instead of exhaustive arms","description":"## Summary\n\n`eval_bool_cached` uses `_ =\u003e Err(\"type\".into())` for BinOp instead of listing arithmetic operators explicitly (unlike `eval_bool`). A new `BinOp` variant silently errors at runtime in the cached path while the non-cached path would fail to compile if exhaustive.\n\n## Steps to Reproduce\n\n1. Add `BinOp::Mod` to formula.\n2. Cached evaluator silently type-errors; non-cached fails at compile if arms are exhaustive.\n\n## Acceptance Criteria\n\n- `eval_bool_cached` BinOp match is exhaustive in the same style as `eval_bool`.\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `eval_bool_cached` ~L700\n\n## PR / Attribution\n\nPR #100 F12. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of THREE beads that are the same defect -- improvise-492x (formula parser defaults unknown aggregates to Count and unknown comparisons to Ge), improvise-4lgj (the .improv pest walker's _ =\u003e {} arms silently skip unhandled grammar rules), improvise-vyrx (eval_bool_cached wildcards BinOp where eval_bool lists it).\n\nThey are one work unit: same fix shape, same doctrine, and splitting them across agents means three people re-deriving the same argument. design-principles.md section 2 states it outright -- 'Prefer exhaustive match over if let or _ =\u003e wildcards. When a new Axis variant or AppMode is added, non-exhaustive matches produce compile errors that guide you to every place that needs updating.' Each of these three is a place where that guidance was not followed and the failure is therefore SILENT rather than a compile error.\n\nNote the asymmetry that makes vyrx the sharpest: the cached and non-cached paths DISAGREE, so a new BinOp works in one and errors in the other.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:30Z","closed_at":"2026-09-21T21:58:30Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\neval_bool_cached's _ =\u003e Err(\"type\") replaced with the same explicit arm list eval_bool carries. Verified by INJECTING BinOp::Mod: the build now fails at four sites where it previously failed at three -- the fourth being eval_bool_cached, which used to compile silently.\n\nA shape-sweep then found a SECOND instance one level up: eval_bool and eval_bool_cached also wildcarded the outer Expr match, so a new Expr variant usable as a condition would have been a silent type error in BOTH paths. Both now list the six non-comparison variants; injecting Expr::And fails four sites where it previously failed two.\n\nThe rule the sweep produced, which is the generalisable part: a wildcard falling through to the GENERAL path is safe by construction; one falling through to a REFUSAL or a SPECIALISATION is not. That cleared three look-alikes (CellValue::as_f64, CategoryKind::of_name, and the new eval_agg arm) which were left deliberately.","external_ref":"gh-pr-100-F12-binop","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-a36c","title":"[test] count_aggregation assertion is \u003e= 1.0 instead of exact value","description":"## Summary\n\nTest `count_aggregation` asserts `val \u003e= 1.0` instead of exact `1.0` for `COUNT(Sales)` at `Region=East`. Any inflated count ≥ 1 still passes.\n\n## Steps to Reproduce\n\n1. Fault-inject `AggFunc::Count` to return `values.len() as f64 + 999.0`.\n2. Test still passes.\n\n## Acceptance Criteria\n\n- Assert exact expected count (1.0 for the fixture).\n\n## Doctrine\n\n`doctrine(check_the_checker)`\n\n## Location\n\n`crates/improvise-core/src/model/types.rs` — `count_aggregation` ~L1467\n\n## PR / Attribution\n\nPR #100 F10. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"GROOM 2026-09-21: one of FIVE beads that are the same defect -- improvise-lm5e (Debug-string tests verify effect names, not correctness), improvise-gjat (sequence-associativity test proves a tautology), improvise-ub4o (toggle-group-involution does not apply effects between calls), improvise-sfwy (records_mode_resolve_display asserts non-empty), improvise-a36c (count_aggregation asserts \u003e= 1.0 instead of the value).\n\nOne work unit. A sixth, improvise-jkxr, is already CLOSED and is the worked example of how to do these: inject the fault first, watch the OLD assertion pass, then strengthen it and watch it fail. In jkxr's case replacing a merge with a replace left the old assert!(!item_names.is_empty()) passing; the strengthened assert_eq! failed with left: [\"B\"], right: [\"A\", \"B\"]. Evidence in context/test-evidence/formula-and-io.md.\n\nDo NOT fix these by inspection. design-principles.md section 7 names this exact class -- 'A check that looks like the answer, answering a neighbouring question' -- and records that the FIRST repair of one of them was itself vacuous. The acceptance for each is a demonstrated fault injection, not a stronger-looking assertion.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-core","owner":"box@local","created_at":"2026-09-21T20:49:02Z","created_by":"external-grok-bot","updated_at":"2026-09-23T02:44:31Z","started_at":"2026-09-23T01:18:33Z","closed_at":"2026-09-23T02:44:31Z","close_reason":"PR #143. count_aggregation asserted \u003e= 1.0 where the exact count is knowable. WORSE THAN FILED: the ticket's own suggested rival (len + 999.0) breaks the EMPTY case, which a different test catches -- so preserve the empty case and ALL 1699 TESTS PASS while COUNT over any non-empty set is wrong by 999. The only thing standing between a wrong COUNT and a green suite was the identity element, the one input it gets right by accident. Two assertions with an ASYMMETRIC fixture (1 matching cell scoped, 3 unscoped), so no single constant satisfies both. Adversarial review confirmed by injection: the constant-1.0 rival fails only the second assertion, the +999 rival only the first, and the neighbour eval_agg_over_no_rows_is_zero_only_where_an_identity_exists passes under BOTH.","external_ref":"gh-pr-100-F10-count","labels":["adversarial-review","pr-100","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -126,10 +129,10 @@ {"_type":"issue","id":"improvise-12ha","title":"Multi-tab browser editing: a SharedWorker cannot host OpfsFs (measured), so pick a shape","description":"CONTEXT: improvise-i0v9 is the bug (a second tab is dead and silent). This is the\ncapability ticket. The user ruled that read-only second tabs are acceptable \"as\nlong as that's clear\", then asked whether the worker should be shared between\ntabs. The answer was measured rather than recalled.\n\nTHE DECISIVE MEASUREMENT, all three engines:\n\n chromium SharedWorkerGlobalScope, isWorkerGlobalScope true, getDirectory ok,\n getFileHandle ok, hasCreateSyncAccessHandle FALSE\n \"TypeError: fh.createSyncAccessHandle is not a function\"\n firefox identical\n webkit getDirectory itself fails (no OPFS at all -- the gap opfs.spec.js\n already skips on)\n\ncreateSyncAccessHandle is spec'd [Exposed=DedicatedWorker] and both engines\nimplement that faithfully. NOT a flag, NOT transient, NOT permissions. The real\nOpfsFs was then run unmodified inside a module SharedWorker and failed at exactly\nthat call.\n\nSO: a SharedWorker CANNOT host OpfsFs as written. Gotcha 28's premise -- the\nhandle's read/write/truncate/flush ARE blocking, which is what let Storage stay\nsynchronous -- is a DEDICATED-WORKER-ONLY premise.\n\nTwo things cut the other way and are worth keeping:\n - SharedWorker itself is in fine shape: present in all three, MODULE shared\n workers work in all three, and two tabs demonstrably share ONE instance (same\n instance id, connections: 2, both served).\n - WebKit's SharedWorker is healthy; its OPFS is what is missing. WebKit is no\n worse off under a shared-worker design than it is today.\n\nTHREE WAYS OUT, ALL PROBED:\n\nA. SharedWorker host with an ASYNC storage seam. createWritable() IS\n [Exposed=Window,Worker] and works in a shared worker (measured, chromium and\n firefox); reads need no handle at all (getFile().text()). But this is\n precisely what gotcha 28 exists to avoid: Storage::read/write are sync,\n App::storage is a Box\u003cdyn Storage\u003e, effect::Save calls it synchronously\n inside Effect::apply inside App::apply_effects -- the one mutation site. An\n async fn in Effect::apply is a DIFFERENT TRAIT, and it reaches the native TUI\n too. Load-bearing, not plumbing.\n NARROWER VARIANT worth costing first: keep Storage sync over an IN-MEMORY\n document, flushed asynchronously from the message loop -- the shape\n commit_deletes() already has. Storage never becomes async; a fifth awaited\n method persist() joins the four. UNCOSTED: what holds a document in memory\n across a LoadModel, and what a crashed tab loses that a sync handle would not.\n\nB. Nested dedicated worker under a SharedWorker. DEAD. Firefox allows it and the\n nested worker gets a real working sync handle with OpfsFs unchanged; Chromium\n and WebKit do not implement nested workers in shared workers at all\n (\"ReferenceError: Worker is not defined\"). The one shape that would have cost\n nothing works in exactly one engine.\n\nC. Keep the dedicated worker, add a RELAY owning no storage -- a SharedWorker or\n BroadcastChannel moving ClientFrame/ServerFrame JSON between follower tabs and\n the one tab whose dedicated worker holds the handle.\n improvise_protocol::frame is already THE one pair for all deployments\n (improvise-xks6), and the argument recorded there is explicitly\n transport-agnostic. A third transport is the case that consolidation was made\n for.\n - probably free: Worker::on_frame's \"one client by construction\". Gotcha 45\n already says the Addressed filter is honoured against a future SharedWorker.\n Session ids, fan-out and per-session mirrors need nothing new.\n - LOAD-BEARING: leader election and failover. The handle IS released when the\n leading tab closes, but bindings.rs::boot has NO RETRY PATH, so a follower\n cannot be promoted without a reload -- the same \"dead until you reload\"\n through a different door.\n - LOAD-BEARING: the relay must serialize frames onto one tail promise.\n web/worker.js had this exact race and web/serial.js exists because of it\n (gotcha 48). A relay forwarding async reintroduces it ACROSS TABS, where\n gotcha 33 answers an overtaken update by DROPPING it.\n - REVISIT: persist-on-dirty. Storage is whole-document (gotcha 41), so N\n editing tabs is N whole-file rewrites through one handle.\n\nRECOMMENDED ORDER (agent's read, not a ruling): C first, A's narrow variant\nsecond, read-only as the fallback it now is. C's real price -- leader failover --\nis THE SAME MISSING RETRY PATH the read-only fallback needs anyway, so that work\nis not wasted either way.\n\nDO NOT treat this as small because Sessions is ready. That readiness is real and\nis the good news; the no is entirely on the storage side.\n\nUNTESTED: Safari proper. Playwright's WebKit has no OPFS in any context so it is\nunaskable here. Safari 17+ ships OPFS; whether createSyncAccessHandle is\ndedicated-only there too is an EXPECTATION, not a measurement.\nUNPROBED: whether BroadcastChannel beats a SharedWorker relay for C. It is in all\nthree engines and needs no new worker, possibly making C cheaper -- but it fans\nout to everyone, so the Addressed filter would be doing real work rather than\nbeing honoured defensively. One probe settles it.\n","notes":"NEXT CHEAPEST PROBE: whether BroadcastChannel beats a SharedWorker relay for option C. It is in all three engines and needs no new worker. The catch is that it fans out to everyone, so the Addressed filter would be doing real work rather than being honoured defensively (gotcha 45).","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:42:20Z","labels":["architecture","browser","opfs"],"dependencies":[{"issue_id":"improvise-12ha","depends_on_id":"improvise-i0v9","type":"discovered-from","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7gip","title":"[bug] OpfsFs::open's dedicated-worker guard checks WorkerGlobalScope, which a SharedWorker satisfies","description":"MEASURED in all three engines at 1f28868.\n\nOpfsFs::open() guards with:\n\n .dyn_into::\u003cweb_sys::WorkerGlobalScope\u003e()\n .map_err(|_| anyhow!(\"OPFS sync access handles require a dedicated Web Worker\"))?\n\n`self instanceof WorkerGlobalScope` is TRUE in a SharedWorkerGlobalScope. So a\nshared worker sails past this check and fails much later as a raw TypeError deep\nin the wasm glue:\n\n [firefox] workerError \"TypeError: arg0.createSyncAccessHandle is not a\n function\" at opfs/pkg/opfs_contract.js:93\n\nThe message names the right requirement; the type cannot check it.\ndyn_into::\u003cweb_sys::DedicatedWorkerGlobalScope\u003e() would.\n\nThis is doctrine(an_invariant_is_made_unrepresentable_not_validated) HALF DONE:\na guard was reached for, and the type chosen is one level too wide, so the\ninvariant is validated-and-wrong rather than unrepresentable.\n\nopfs.rs is wasm32-only with no Rust tests (0t in the inventory), so there is no\ncargo test that can fail first. The instrument that CAN fail first is harness/ --\nthe probe above is exactly the reproduction, and harness/opfs-contract already\nboots the real bundle in a real worker.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:40:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:57Z","closed_at":"2026-09-21T21:58:57Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored. Playwright 42 passed / 0 failed / 4 skipped (WebKit, no OPFS) -- up from 37, so the count MOVED. All gates green.\n\nGuard is now DedicatedWorkerGlobalScope.\n\nTHE ONE-LINER DOES NOT COMPILE: the type is web-sys feature-gated, so improvise-io/Cargo.toml needed a line too. That is why it looked free and was not.\n\nNOT VERIFIED: that a SharedWorker is actually refused -- the harness has no SharedWorker page. What is asserted is only that a DEDICATED worker still satisfies the narrow type, i.e. the fix did not break the shipped path. Stated explicitly in the evidence rather than implied. improvise-12ha owns the SharedWorker question.","labels":["browser","bug","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7xyx","title":"FrameGroup::cat is dead in both front ends and its doc comment promises an echo nobody composes","description":"FrameGroup::cat is read by NEITHER front end -- dead in both. Its doc comment\nsays it is \"echoed back in ToggleGroup\", and resolve.rs has NO ToggleGroup arm\nat all, so the echo it promises cannot happen from a browser.\n\nBy design-principles.md section 6 this is the expensive class: a reason that\nlives only in a comment, where nothing checks it against the code. The comment\nis a promise about a client that does not exist.\n\nTwo outcomes are acceptable: compose ToggleGroup in resolve.rs and make the\ncomment true (z is on the silent list anyway, so this would close part of that),\nor delete the field and the comment together. What is not acceptable is leaving\na doc comment describing a protocol interaction no code performs.\n\nAlso noticed in the same pass: AxisFrame::cats is PARTIAL on both ends -- the\ncount is used, the category NAMES are drawn by neither, so the grid's corner\ncell is blank in both surfaces. Possibly intended; worth a decision either way.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:48:03Z","labels":["docs","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-hs0u","title":"[bug] gg is implemented in the resolver and unreachable from any browser key","description":"DEMONSTRATED at 1f28868.\n\n PROBE 'g': browser = Upstream (silent)\n PROBE 'G': browser = Local[SetSelected { row: 1, col: 0 }, SetRowOffset(1)]\n\ngrid_move's GridMove::ToStart(true) arm EXISTS and is CORRECT. jump-first-row is\nbound only behind the g prefix (keymap.rs:639), and prefixes are unconditionally\nUpstream in resolve(). So a browser user can reach the last row and cannot get\nback to the first.\n\n0 and Home (jump-first-col) both work, so the asymmetry is ROW-ONLY and reads as\na bug rather than as an absence. The resolver arm is dead code in the browser\ntoday.\n\nSame root cause as the paste ticket: every prefix is Upstream, and\nHost::handle answers ClientFrame::Key with an explicit empty vec, so an upstream\nkey is a black hole rather than a slow path (gotcha 47). Fixing prefixes\nresolvably closes gg, yy and ZZ together.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:56Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:51:05Z","started_at":"2026-09-23T02:51:05Z","lease_expires_at":"2026-09-23T02:56:05Z","heartbeat_at":"2026-09-23T02:51:05Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-hs0u","depends_on_id":"blocked-by:improvise-lut7","type":"blocks","created_at":"2026-09-21T20:40:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hs0u","depends_on_id":"improvise-lut7","type":"related","created_at":"2026-09-21T21:31:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hs0u","title":"[bug] gg is implemented in the resolver and unreachable from any browser key","description":"DEMONSTRATED at 1f28868.\n\n PROBE 'g': browser = Upstream (silent)\n PROBE 'G': browser = Local[SetSelected { row: 1, col: 0 }, SetRowOffset(1)]\n\ngrid_move's GridMove::ToStart(true) arm EXISTS and is CORRECT. jump-first-row is\nbound only behind the g prefix (keymap.rs:639), and prefixes are unconditionally\nUpstream in resolve(). So a browser user can reach the last row and cannot get\nback to the first.\n\n0 and Home (jump-first-col) both work, so the asymmetry is ROW-ONLY and reads as\na bug rather than as an absence. The resolver arm is dead code in the browser\ntoday.\n\nSame root cause as the paste ticket: every prefix is Upstream, and\nHost::handle answers ClientFrame::Key with an explicit empty vec, so an upstream\nkey is a black hole rather than a slow path (gotcha 47). Fixing prefixes\nresolvably closes gg, yy and ZZ together.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:56Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:42Z","started_at":"2026-09-23T02:51:05Z","closed_at":"2026-09-23T03:29:42Z","close_reason":"Merged in PR #161. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-hs0u","depends_on_id":"blocked-by:improvise-lut7","type":"blocks","created_at":"2026-09-21T20:40:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hs0u","depends_on_id":"improvise-lut7","type":"related","created_at":"2026-09-21T21:31:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0ygd","title":"[bug] Tab on the +measure column silently discards the commit that Enter accepts","description":"DEMONSTRATED at 1f28868.\n\n PROBE +measure at (0,4), extent GridExtent { rows: 1, cols: 5 }\n PROBE Tab on +measure, the LAST row:\n browser = Upstream (silent)\n tui = doc_changed true, status \"Added new record row\"\n\na_locally_committed_creating_column_agrees_with_the_host passes for\nCreateKind::Measure -- it presses ENTER. Press TAB on the very same cell and:\n\n - resolve.rs::advanced() opens with\n if matches!(advance, Advance::Right) \u0026\u0026 state.grid.records\n \u0026\u0026 row \u003e= row_max \u0026\u0026 col \u003e= col_max { return None; }\n - None propagates out of create_column -\u003e commit -\u003e composed -\u003e Upstream, so\n the WHOLE COMMIT is discarded, not just the advance. The typed measure name\n is lost silently.\n - CommitAndAdvance::execute (cmd/commit.rs) takes the\n at_bottom_right \u0026\u0026 ctx.is_records_mode() branch instead: runs AddRecordRow,\n moves to (row+1, 0) and re-enters the editor. That is the bulk-entry Tab\n contract (see the records-mode Tab memory: Tab always advances to the next\n useful place).\n\n+measure is ALWAYS the last column, so col \u003e= col_max holds for it by\nconstruction. This fires on every single-row records grid and on the last row of\nany records grid.\n\nThis sits inside the already-filed \"records mode is silent in the browser\" gap,\nand closing that gap subsumes it -- but carry the specific observation into that\nticket, because \"records mode is silent\" does not predict \"the same cell answers\nEnter and drops Tab\".\n","notes":"TUI HALF FIXED in PR #135 (merge 42ec30d). LEFT OPEN: the browser half is this\nticket's own subject and is blocked -- see below.\n\nIT REPRODUCED ONLY PARTLY, AND THE SPLIT MATTERS. The headline (\"silently\ndiscards the commit\") is BROWSER-ONLY. In the TUI, driving App::handle_key on a\none-record grid [Region, +cat, Revenue, +measure] with the cursor at (0,3),\n`i` C o s t Tab gives:\n measures=[\"Revenue\",\"Cost\"] \u003c- the commit LANDED\n sel=(1,0) status=\"Added new record row\"\n rows=2 cols=5 \u003c- a record row nobody asked for\nSo the TUI KEEPS the commit and ADVANCES WRONGLY, leaving the measure just created\nunreachable. Same root cause as the browser's discard, seen from the other end.\n\nTHE CAUSE IS A STALE INDEX -- not an abort, not an ordering. AdvanceDir::Right\nreads at_bottom_right off CursorState::col_count, the PRE-COMMIT column count\n(apply_effects rebuilds only after the batch, gotcha 23). A +measure commit\nINSERTS A COLUMN AT THE CURSOR'S OWN INDEX and pushes + one right, exactly as +cat\ndoes, so the cursor is no longer at the right edge. Enter survived ONLY BY\nACCIDENT: EnterAdvance aborts at bottom-right, and the SetSelected restore already\nat the head of the batch held the cursor.\n\nTHE MOST VALUABLE FINDING: context/code/command.md ARGUED THE BUG INTO EXISTENCE,\nand both clauses were false. Verified verbatim on main before the fix:\n \"The measure + shares none of this, and not by luck -- it is the last column,\n so there is no column to its right for an index to shift into, and both\n advances are already special-cased at the bottom-right edge.\"\nThe column that shifts is +measure ITSELF. And \"already special-cased at the\nbottom-right edge\" NAMES THE BUG, not a safeguard. Corrected in the PR with the\ncorrection recorded rather than quietly patched.\n\nTHE DISCRIMINATOR IS THE BUFFER, NOT THE COLUMN, and this decided the fix's shape:\n+measure is ALWAYS the rightmost column, so `cursor.col \u003e= col_max` in records\nmode names THAT ONE CELL AND NO OTHER -- the entire \"Tab at the bottom-right\nstarts the next record\" bulk-entry contract is reachable only by tabbing onto the\n+ with an EMPTY buffer. A guard keyed on the column alone fixes this ticket and\nSILENTLY DELETES BULK ENTRY. The injected rival demonstrates exactly that: dropping\nthe value-awareness passes the new test and fails\ntab_on_an_empty_new_measure_column_still_starts_a_record_row.\n\nWHY THE BROWSER HALF STAYS OPEN AND IS BLOCKED: resolve.rs::create_column early-\nreturns for CreateKind::Category, but CreateKind::Measure falls through to\nadvanced(), whose bottom-right None propagates out through commit -\u003e composed -\u003e\nUpstream, DISCARDING THE WHOLE COMMAND VECTOR -- strictly worse than the host's\nmisbehaviour. But that path is NOT REACHABLE at this head: improvise-bd9o put the\nrecords guard at the entrance of edit_or_drill and its CellId::Create arm declines\nwith not_yet(\"creating a column\"), so the editor never opens on a creating column\nand create_column is dead code. A browser regression test cannot be written before\nimprovise-x0xd's work -- THERE IS NO DOOR TO DRIVE IT THROUGH.\n\nUSER-VISIBLE CHANGE BEYOND THE TICKET'S LETTER, FLAGGED FOR ED: Enter on +measure\npreviously re-entered the editor there; it now behaves exactly like +cat, landing\non the created column in normal mode. That is a deliberate consequence of one\ntruthful predicate at one call site, and command.md already argued the two +\ncolumns are symmetric -- but it is a behaviour change and should be confirmed.\nAlso: +cat with an EMPTY buffer now falls through to the advance instead of\nstaying put (it creates no column, so the \"staying put lands on the inserted\ncolumn\" rationale does not apply). No test pinned the old behaviour either way.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T22:51:09Z","labels":["browser","bug","parity"],"dependencies":[{"issue_id":"improvise-0ygd","depends_on_id":"improvise-x0xd","type":"blocks","created_at":"2026-09-22T22:51:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wk18","title":"[bug] the advertised-key guard cannot see help.rs literal() lines, so 17 keys are unchecked","description":"DEMONSTRATED at 1f28868.\n\nevery_advertised_command_answers_in_its_own_mode iterates help::advertised(\u0026mode),\nwhich yields (command, args, caption) triples. help.rs ALSO emits 17 literal(..)\nlines naming a KEY STRING and no command, so the guard cannot see them.\nweb/app.js paintHelp renders them verbatim.\n\nOf the 17:\n gg, yy, ZZ -- all three are prefix keys, all three silent in the browser\n :wq :q :q! :import :add-cat :add-item :formula :add-view :set :help and the\n rest -- the whole ex-command reference page; the browser declines at the door,\n so it says something, but it renders a full page of commands it cannot run.\n\nSo the browser paints a help overlay in which, conservatively, an entire page\nplus three scattered lines are inert, and the test that exists to stop exactly\nthat cannot see any of it.\n\nThis is the cheapest of the parity findings and it PROTECTS THE OTHERS: it is\nthe mechanism this repo uses to stop black holes, and it looks away by\nconstruction from the two lines that the gg and paste tickets are about. Without\nit the next gg lands the same way and nothing notices.\n\nFix: either give literal() a command name so the guard can reach it, or add a\nsecond guard over the help pages.\n","notes":"BLOCKED AND RE-SCOPED 2026-09-21 by the keymap agent (PR #112), which took improvise-636d and deliberately did NOT half-do this one. Its reasoning and its proposed design, which is worth more than the ticket had:\n\nWHY IT COULD NOT BE DONE FROM THE KEYMAP GROUND:\n - the guard needs resolve() AND the `furnished` fixture, both private to resolve.rs's mod tests. Duplicating the fixture elsewhere is the disease this repo names repeatedly.\n - the HelpLine reshape both tickets call for breaks the struct patterns at improvise-wasm-client/src/render.rs:346 and src/ui/help.rs:72 -- NEITHER uses `..`, so the reshape is a compile error in a file another agent owns.\n\nTHE PROPOSED SHAPE, designed but not written: keep HelpLine FLAT and PUBLIC, and introduce an internal\n\n HelpRow {\n Keys { mode, command, args, description },\n Prefix { mode, prefix, then, .. },\n Ex { line, .. },\n Prose (HelpLine),\n }\n\nthat pages() RENDERS into HelpLine. One source, no renderer edit, and the guard walks the rows rather than the rendered lines.\n\nThat satisfies uln5's stated blocking cost -- \"carry command/mode/args onto the variant\" -- WITHOUT touching render.rs, which is what made it look expensive.\n\nIT ALSO SPLITS wk18's 17 literal() lines honestly rather than treating them as one lump:\n - gg / yy / ZZ are checkable via Keymaps::prefix_keymap\n - the 14 ex-commands are checkable against the registry from improvise-command's tests\n\nSo the two beads are one change to HelpRow plus two different guards, not one guard over a uniform list.\n\nNOTE THE GROUND THIS NEEDS: resolve.rs's test module, help.rs, and a test that can reach the registry. That is a different partition from the one this wave used, so schedule it with whoever owns resolve.rs rather than with the keymap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:15Z","labels":["bug","parity","testing"],"dependencies":[{"issue_id":"improvise-wk18","depends_on_id":"improvise-uln5","type":"related","created_at":"2026-09-22T02:27:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lut7","title":"[bug] p is composed by the browser and can never do anything, so the name-level derivation lies","description":"DEMONSTRATED at 1f28868.\n\n PROBE yank 'y': browser = Upstream (silent), twice -- y is a Binding::Prefix\n PROBE yank: tui = yanked Some(Number(1200.0)), status \"Yanked\"\n PROBE p: browser = Ignored (silent)\n\ny is Binding::Prefix, and resolve() maps EVERY prefix to Upstream\nunconditionally. Command::SetYanked exists on the wire (reduce.rs:32, folded at\n:212) and NOTHING in resolve.rs composes it. So ViewState::yanked is permanently\nNone in a browser, and composed(\"paste\") returns Some(vec![]) -\u003e\nKeyOutcome::Ignored -\u003e silence.\n\nTHE REASON TO FILE THIS RATHER THAN SHRUG: paste appears on NEITHER the declined\nlist NOR the host-needed list. Every mechanical derivation of \"what the browser\ncan do\" -- including the_command_names_that_still_need_the_host_are_these --\nreports it as SUPPORTED. It is dead, and it is dead for a STATE reason that\nname-level derivations cannot see. That is this repo's most-cared-about failure\nmode: a green derivation over a broken thing.\n\nhelp.rs:546-554 advertises yy and p together as a copy/paste pair, so the browser\npaints a help page promising both.\n\nFix is one arm -- \"yank\" =\u003e Some(vec![Command::SetYanked(..)]), and the cache\nalready holds the value via cell_at_cursor().edit_text -- plus making prefixes\nresolvable. OR, if prefixes stay upstream, move p onto declined() so the\nderivation tells the truth. Either is acceptable; leaving the derivation lying\nis not.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:18Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:51:09Z","started_at":"2026-09-23T02:51:09Z","lease_expires_at":"2026-09-23T02:56:09Z","heartbeat_at":"2026-09-23T02:51:09Z","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lut7","title":"[bug] p is composed by the browser and can never do anything, so the name-level derivation lies","description":"DEMONSTRATED at 1f28868.\n\n PROBE yank 'y': browser = Upstream (silent), twice -- y is a Binding::Prefix\n PROBE yank: tui = yanked Some(Number(1200.0)), status \"Yanked\"\n PROBE p: browser = Ignored (silent)\n\ny is Binding::Prefix, and resolve() maps EVERY prefix to Upstream\nunconditionally. Command::SetYanked exists on the wire (reduce.rs:32, folded at\n:212) and NOTHING in resolve.rs composes it. So ViewState::yanked is permanently\nNone in a browser, and composed(\"paste\") returns Some(vec![]) -\u003e\nKeyOutcome::Ignored -\u003e silence.\n\nTHE REASON TO FILE THIS RATHER THAN SHRUG: paste appears on NEITHER the declined\nlist NOR the host-needed list. Every mechanical derivation of \"what the browser\ncan do\" -- including the_command_names_that_still_need_the_host_are_these --\nreports it as SUPPORTED. It is dead, and it is dead for a STATE reason that\nname-level derivations cannot see. That is this repo's most-cared-about failure\nmode: a green derivation over a broken thing.\n\nhelp.rs:546-554 advertises yy and p together as a copy/paste pair, so the browser\npaints a help page promising both.\n\nFix is one arm -- \"yank\" =\u003e Some(vec![Command::SetYanked(..)]), and the cache\nalready holds the value via cell_at_cursor().edit_text -- plus making prefixes\nresolvable. OR, if prefixes stay upstream, move p onto declined() so the\nderivation tells the truth. Either is acceptable; leaving the derivation lying\nis not.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-web","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:18Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:29:43Z","started_at":"2026-09-23T02:51:09Z","closed_at":"2026-09-23T03:29:43Z","close_reason":"Merged in PR #161. Closed by the orchestrator after the session handover; the implementing agent's claim was abandoned when its process exited. Details of the fix, the rival injected and what the ticket did not predict are on the PR thread.","labels":["browser","bug","parity"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7iww","title":"Cached circular formula publishes the last iteration as a valid number","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-core/src/model/types.rs:549-571,624-641\n\nStore Revenue=10, add Revenue=Revenue+1. Direct Model::evaluate correctly returns an error. After recompute_formulas, evaluate_aggregated(OverHidden) returns Number(26). The cached evaluator seeds from raw data then iterates 16 times without convergence and leaves the last numeric result in formula_cache. This is the displayed path because default views hide virtual categories. On a workbook with an explicit stored measure target, a circular formula looks like a real calculated value. Separate from closed improvise-ubu, whose depth guard prevents recursive stack overflow; that guard does not run in the cached reference path. Mark nonconverging results as errors and use this direct/cached disagreement to drive a shared evaluator-policy refactor. Duplicate audit: circular, fixed, full descriptions; read improvise-ubu.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::workbook::Workbook;\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn circular_formula_not_a_plausible_cached_number() {\n use improvise_io::{formula::parse_formula,model::{Aggregation,cell::{CellKey,CellValue}}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Revenue = Revenue + 1\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))));\n w.model.recompute_formulas(\u0026[\"_Index\".into(),\"_Dim\".into()]);\n let actual=w.model.evaluate_aggregated(\u0026key,Aggregation::OverHidden);\n assert!(matches!(actual,Some(CellValue::Error(_))),\"circular formula became {actual:?}\");\n}\n```\nRun cargo test circular_formula_not_a_plausible_cached_number -- --nocapture. Current source failed this test.\n\n## Acceptance Criteria\nThe focused regression passes without weakening the expected behavior; ordinary cases still agree, with both debug and release arithmetic considered.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:40Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:29Z","closed_at":"2026-09-21T21:58:29Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\nrecompute_formulas stopped at MAX_EVAL_DEPTH and left the 16th iteration in formula_cache. It now records the keys that moved on each pass and, if the last pass still moved any, writes Error(\"circular\") over them. Both paths checked: evaluate was already correct; the cached evaluate_aggregated(OverHidden) was the broken one and is the DISPLAYED path, because default views put the virtual categories on Axis::None -- so a circular formula looked like a real calculated value.\n\nThe cost, and why it is right: the fixed point can only CONFIRM convergence with a pass that changes nothing, so a 16-deep chain is now reported circular -- the same bound evaluate_depth already enforces, so the two evaluators AGREE rather than diverge.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3b2b","title":"Deleting a virtual category from the category panel disables records mode","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-command/src/command/cmd/panel.rs:597-609; ui/effect.rs:295-302; core/src/workbook.rs:71\n\nThe panel deliberately shows _Index/_Dim/_Measure (improvise-vjms), but its delete command treats them as ordinary removable categories. Execute DeleteCategoryAtCursor at initial cursor 0 (_Index), then ToggleRecordsMode. Status says Records mode but layout.is_records_mode() is false: the new view has no _Index to set on Row. The invariant that virtual categories always exist is broken through a shipped user action. Protect required categories at a shared authoritative boundary, with both TUI and wire tests; merely hiding the delete key leaves the wire mutation unguarded. Duplicate checks: virtual, reserved, category; read improvise-vjms, which decides visibility, not removability.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn category_panel_cannot_delete_required_virtual() {\n use improvise::{ui::app::App,command::{cmd::{Cmd,panel::DeleteCategoryAtCursor},key::{KeyCode,KeyModifiers}}};\n let mut app=App::new(revenue_book(),None);\n app.view_state.cat_panel_cursor=0;\n let fx=DeleteCategoryAtCursor.execute(\u0026app.cmd_context(KeyCode::Char('d'),KeyModifiers::NONE));app.apply_effects(fx);\n let fx=improvise::command::cmd::grid::ToggleRecordsMode.execute(\u0026app.cmd_context(KeyCode::Char('r'),KeyModifiers::NONE));app.apply_effects(fx);\n assert!(app.layout.is_records_mode(),\"deleting _Index prevents records mode; status {}\",app.view_state.status_msg);\n}\n```\nRun cargo test category_panel_cannot_delete_required_virtual -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","notes":"CORE HALF CLOSED by PR #114 (wave/s-resolve-keys, head 614d818), guard placed at the MODEL rather than in the two Cmds.\n\nModel::remove_category now refuses a virtual category outright. That placement is the point and it is what this bead asked for: hiding the key in DeleteCategoryAtCursor would leave ModelCommand::RemoveCategory unguarded ON THE WIRE, so a connected client could still delete _Index/_Dim/_Measure and disable records mode for everyone.\n\nLanded alongside improvise-uej8's cursor fix, since both are the same keypress.\n\nKeep this bead open until the reproduction category_panel_cannot_delete_required_virtual is un-ignored and passing -- that is the acceptance, and it lives in tests/bug_hunt_reproductions.rs.\nALREADY FIXED, same wave as improvise-uej8 (7a44616 / PR #114). Model::remove_category refuses _Index/_Dim/_Measure, guarded by model::types::model_tests::a_required_virtual_category_cannot_be_removed, passing at HEAD. Verified by the agent dispatched to uej8, which found this as uej8's second half. Stale-open.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:39:21Z","closed_at":"2026-09-22T06:39:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-s08t","title":"Unknown names inside aggregate formulas silently select unrelated data","description":"Report-only hunt at 70025e2. No source changes.\n\nLocation: crates/improvise-core/src/model/types.rs:66-83\n\nRevenue=10 and Total=SUM(Typo), where Typo does not exist, yields Number(10) instead of a reference error. When find_item_category fails, eval_agg simply leaves partial unconstrained and aggregates every numeric measure matching the context. A typo produces a plausible wrong total. This is distinct from computed measures being bypassed: missing identifiers must fail before the raw-value fast path. Cover unknown refs across aggregate functions and cached/direct evaluation. Duplicate checks: reference, SUM, aggregate, formula, full existing descriptions.\n\n## Steps to Reproduce\nStandalone Rust 2024 package with improvise and improvise-io path dependencies:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md}};\nfn revenue_book()-\u003eWorkbook {\n use improvise_io::model::cell::{CellKey,CellValue};\n let mut w=Workbook::new(\"T\");w.add_category(\"Region\").unwrap();w.add_item(\"Region\",\"East\").unwrap();w.add_item(\"_Measure\",\"Revenue\").unwrap();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Number(10.0));w\n}\n#[test] fn unknown_aggregate_reference_is_not_all_measures() {\n use improvise_io::{formula::parse_formula,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();w.model.add_formula(parse_formula(\"Total = SUM(Typo)\",\"_Measure\").unwrap());\n let key=CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Total\".into())]);\n assert!(matches!(w.model.evaluate(\u0026key),Some(CellValue::Error(_))),\"actual {:?}\",w.model.evaluate(\u0026key));\n}\n```\nRun cargo test unknown_aggregate_reference_is_not_all_measures -- --nocapture. Reproduced failing against unchanged code.\n\n## Acceptance Criteria\nCorrect the described observable behavior; preserve ordinary cases with focused regressions and explicit edge-case policy.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:35:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:28Z","closed_at":"2026-09-21T21:58:28Z","close_reason":"Fixed in the 2026-09-21 wave, integrated into main at 1574 passed / 0 failed / 7 ignored, all gates green, every guard fault-injected.\n\neval_agg narrowed its partial key only 'if let Some(cat) = find_item_category(name)'; a failed lookup left the key UNCONSTRAINED, so SUM(Typo) aggregated every numeric measure matching the context and returned a plausible total. Now ok_or_else(|| format!(\"ref:{name}\"))? before the fast path.\n\nUNPREDICTED: an existing test was STANDING ON this bug to demonstrate a neighbouring property. eval_agg_over_no_rows_is_zero_only_where_an_identity_exists reached 'no rows' through Expr::Ref(\"Nonexistent\") and was the only suite failure when this was fixed. Not a vacuous assertion -- a CORRECT assertion whose fixture was the defect. Its vehicle is now a name that resolves and has no cells.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -316,7 +319,7 @@ {"_type":"issue","id":"improvise-pby","title":"2.2 Write the README","description":"Replace existing README.md with new one under 250 lines. Sections in order: Title, one-sentence pitch, inline demo GIF, why this exists (Lotus Improv reference), quick start, key bindings, installation (Nix/crates.io/prebuilt - no Homebrew), codebase overview, expectations disclaimer, license. No badges, TOC, or contributing guide.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:32Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:02:52Z","close_reason":"README written with all 10 required sections, committed. License updated to Apache-2.0 per user request.","dependency_count":0,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ihv","title":"2.1 Add Nix tooling for asciinema, VHS, and cargo-dist","description":"Modify flake.nix to add pkgs.asciinema and pkgs.vhs to dev shell nativeBuildInputs. Add cargo-dist if packaged in nixpkgs. Add runtime deps for VHS (ttyd, ffmpeg) if needed. Verify tools work via nix develop --command. Add nix run app or shell script for demo recording workflow.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:30Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-09T07:30:19Z","close_reason":"asciinema, vhs, cargo-dist added to flake.nix dev shell and verified. cargo-dist wrapper enables 'cargo dist' subcommand. scripts/record-demo.sh added.","dependency_count":0,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-yk4","title":"Phase 2: README and demo artifacts","description":"The main launch work. README is 80% of the launch, demo artifacts are 20%. Covers Nix tooling for asciinema/VHS, writing the README, creating demo GIF and asciinema casts, and verifying all artifacts.","status":"closed","priority":2,"issue_type":"feature","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:49Z","created_by":"Edward Langley","updated_at":"2026-04-14T07:47:43Z","closed_at":"2026-04-14T07:47:43Z","close_reason":"All 5 subtasks closed; artifacts verified present and tracked in git (README.md, docs/demo.gif, docs/demo.tape, docs/casts/*.cast).","dependencies":[{"issue_id":"improvise-yk4","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T23:37:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-ihv","type":"blocks","created_at":"2026-04-08T23:37:34Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-odx","type":"blocks","created_at":"2026-04-08T23:37:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yk4","depends_on_id":"improvise-pby","type":"blocks","created_at":"2026-04-08T23:37:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":5,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8nd9","title":"ViewBack and ViewForward carry swzk's hazard and are safe only via forget_view","description":"effect::SwitchView was fixed by improvise-swzk (PR #154): it now pushes its history frame AFTER calling the guarded App::switch_to_view and only if active_view actually moved -- observation rather than prediction, the same answer cursor_epoch and document_generation give (gotchas 24/39/45).\n\nViewBack and ViewForward have THE SAME SHAPE and were not fixed there, deliberately, to keep #154 to its ticket. Both call switch_to_view unguardedly, pushing the current view onto the OPPOSITE stack before a switch they cannot be sure of.\n\nTHEY ARE CORRECT TODAY ONLY BY ACCIDENT: ViewState::forget_view purges both stacks when a view is deleted, so a popped frame always names a live view. That is A GUARD LIVING ONE LEVEL AWAY FROM THE HAZARD -- the exact shape that keeps returning in this repo (gotcha 18's empty `partial`, which was unreachable only because both callers guarded; gotcha 24's reset_cursor table). The class of bug returns the moment a second path can put a dead name on a stack.\n\nSo the question is not \"is this broken now\" (it is not) but \"what makes it stay correct\". Options: give ViewBack/ViewForward the same observe-after-the-fact shape as SwitchView, which costs three lines and removes the dependence entirely; or state the forget_view dependence where someone editing either side will see it.\n\nPrefer the first. A guard that is one level away is a guard that a refactor deletes without noticing, and the cheap fix here is the SAME fix already made next door -- so leaving two siblings with different shapes is itself the hazard.\n\nReachability note: a popped frame naming a deleted view needs forget_view to miss a path. Worth checking whether any code removes a view WITHOUT going through it before deciding how urgent this is. Reported by the improvise-htl6/swzk agent; recorded in context/code/command.md at the time.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:36:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:19Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8nd9","title":"ViewBack and ViewForward carry swzk's hazard and are safe only via forget_view","description":"effect::SwitchView was fixed by improvise-swzk (PR #154): it now pushes its history frame AFTER calling the guarded App::switch_to_view and only if active_view actually moved -- observation rather than prediction, the same answer cursor_epoch and document_generation give (gotchas 24/39/45).\n\nViewBack and ViewForward have THE SAME SHAPE and were not fixed there, deliberately, to keep #154 to its ticket. Both call switch_to_view unguardedly, pushing the current view onto the OPPOSITE stack before a switch they cannot be sure of.\n\nTHEY ARE CORRECT TODAY ONLY BY ACCIDENT: ViewState::forget_view purges both stacks when a view is deleted, so a popped frame always names a live view. That is A GUARD LIVING ONE LEVEL AWAY FROM THE HAZARD -- the exact shape that keeps returning in this repo (gotcha 18's empty `partial`, which was unreachable only because both callers guarded; gotcha 24's reset_cursor table). The class of bug returns the moment a second path can put a dead name on a stack.\n\nSo the question is not \"is this broken now\" (it is not) but \"what makes it stay correct\". Options: give ViewBack/ViewForward the same observe-after-the-fact shape as SwitchView, which costs three lines and removes the dependence entirely; or state the forget_view dependence where someone editing either side will see it.\n\nPrefer the first. A guard that is one level away is a guard that a refactor deletes without noticing, and the cheap fix here is the SAME fix already made next door -- so leaving two siblings with different shapes is itself the hazard.\n\nReachability note: a popped frame naming a deleted view needs forget_view to miss a path. Worth checking whether any code removes a view WITHOUT going through it before deciding how urgent this is. Reported by the improvise-htl6/swzk agent; recorded in context/code/command.md at the time.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:36:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:19Z","dependencies":[{"issue_id":"improvise-8nd9","depends_on_id":"improvise-lrrx","type":"related","created_at":"2026-09-23T03:38:10Z","created_by":"agent-review-cmd","metadata":"{}"}],"comments":[{"id":"bad4d021-db16-5343-a024-1a0e13fb61b8","issue_id":"improvise-8nd9","author":"agent-review-cmd","text":"Post-merge review of #154: the 'second path that can put a dead name on a stack' already exists. install_workbook (LoadModel/AcceptWizardModel) clears cursors but not the view stacks. Confirmed in the headless CLI, and filed as improvise-lrrx. The 'it is not broken now' in this description is false.\n","created_at":"2026-09-23T03:38:10Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-jgzw","title":"The tile bar offers _Index and _Dim, because every call site passes category_names()","description":"All four tile call sites pass Model::category_names() -- which returns EVERY category, virtual ones included -- to View::tileable_cats:\n\n ui/project.rs:403, cmd/tile.rs:94, cmd/tile.rs:119, src/ui/tile_bar.rs:180\n\ntileable_cats filters only on axis (it drops the coordinates a drill fixed). In a default view _Index and _Dim sit on Axis::None, which IS user-assignable, so they pass the filter. Consequence: TILE CURSOR 0 IS _Index, not the first regular category.\n\nFound while writing improvise-lm5e's field-naming assertions (PR #148): no assertion in the suite had ever named a category, so nothing said so. Behaviour is UNCHANGED by that PR -- this ticket is the question it exposed, not a regression.\n\nTHE NUANCE THAT DECIDES THIS, and it is why the obvious fix is wrong: _Measure MUST stay tileable. Putting _Measure on an axis is not exotic, it is what every shipped document does (`_Measure: column` is in web/demo.improv and in the format example). So this is NOT \"use regular_category_names() for user-facing pickers\" as the repo-map advises for prompts -- that would remove _Measure and break the common case.\n\nThe categories actually in doubt are _Index and _Dim, which are records-mode machinery: GridLayout AUTO-DETECTS records mode from _Index-on-Row plus _Dim-on-Column, so a user assigning them by hand through the tile bar can construct that state by accident, without the mode change and the keymap that ToggleRecordsMode brings. Worth checking what that actually does before deciding it is bad -- it may be harmless, in which case the answer is to leave it and say why here.\n\nSo the options are: (a) leave it, recording the reason; (b) filter _Index/_Dim specifically, keeping _Measure -- which needs a predicate named for the distinction, since CategoryKind::is_index_or_dim() already exists and says exactly this; (c) decide records mode should be reachable that way and leave it deliberately.\n\nWhichever is chosen, ASSERT A CATEGORY NAME in the tile tests -- the gap that hid this is that none of them did. The rival to inject is a filter that also drops _Measure: it must fail a test that puts _Measure on a column.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T02:16:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:16:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t219","title":"_Drill is one shared view, so two clients drilling contend","description":"_Drill is a single view in Workbook::views, and Workbook::create_view REPLACES (deliberately -- see core.md and improvise-vw47). So two clients drilling at once contend: the second drill overwrites the first client's _Drill configuration, and since a View is shared document state the first client's grid silently becomes the second client's drill.\n\nPRE-EXISTING and NOT WORSENED by improvise-gd28's decomposition -- filed so it is not discovered as a regression of that work. Same is true of _Records.\n\nIt is currently unreachable because the browser cannot enter a drill at all (see the aggregate-addressing seam ticket). Closing that makes this live, so the two should be considered together -- the same masking relationship improvise-6uva has with improvise-up8g.\n\nNeeds a per-client view namespace, which is a real design question: a virtual view is document state by construction today (it lives in Workbook::views and is what GridLayout reads), but a drill is a UI act belonging to one client -- \"the model is shared, the UI is not\" says it should not be in the workbook at all. Note VIRTUAL_VIEW_NAMES are never persisted, so there is no format cost to moving them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T01:36:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-q36k","title":"jq is not in flake.nix, so a clean nix develop cannot run the pr-reviews suite","description":"Found by the improvise-f9qf agent while making scripts/checkers.sh run every\ncheck_the_checker by default.\n\nscripts/pr-reviews needs jq, and JQ IS NOT IN flake.nix. It resolves only via the\ndeveloper's own ~/.nix-profile, so A CLEAN `nix develop` WOULD FAIL\n`checkers.sh pr-reviews`. Nobody has hit it because every machine that has run it\nso far happened to have jq installed independently.\n\nThis is the same class as the bug improvise-f9qf just closed: an environment\ndifference that is invisible on the machine that has the thing, and fatal on the\none that does not. checkers.sh now scrubs the environment to an allowlist, which\nmakes the dev shell closer to CI -- and makes THIS gap reachable, because the\nallowlist keeps PATH but the flake does not put jq on it.\n\nTwo smaller siblings worth checking in the same pass, since they are the same\nquestion:\n - kb-test.sh fails SIXTEEN CASES CONFUSINGLY when tpl (trealla) is absent,\n rather than saying \"no trealla\". Pre-existing, and newly VISIBLE because\n checkers.sh runs everything by default rather than the suite you named.\n - What else does a scripts/ suite shell out to that the flake does not provide?\n The answer is a grep, and the fix is either a flake input or an explicit\n \"this suite needs X\" refusal with a distinct exit code -- checkers.sh already\n uses exit 3 for \"no usable bash\", so the precedent exists.\n\nPREFER THE EXPLICIT REFUSAL over silently skipping: a suite that quietly does not\nrun is how a green gate stops meaning anything, which is the failure improvise-f9qf\nwas filed about.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T23:16:24Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:16:24Z","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/AGENTS.md b/AGENTS.md index b2215230..935a7099 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,9 +126,16 @@ Two agents each adding a workspace member will still collide in reports spurious failures. And **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. -4. **Do not push, do not merge to `main`.** The orchestrator merges. -5. The commit-message trailers, spelled out. -6. **What to report**: branch and commits, what changed per ticket, *what could +4. **Commit frequently, so the work is safe in git.** A WIP commit after the + first green test, and again after every meaningful step. A worktree is not + durable storage: an agent finished a whole ticket with every gate green and + lost all of it, uncommitted, when its worktree was deleted during a disk + cleanup. Tidying the history before the PR is fine; losing it is not. +5. **Do not merge to `main`.** The orchestrator merges. Pushing the agent's own + branch (`git push gh HEAD:refs/heads/`) is encouraged once it is + worth keeping. +6. The commit-message trailers, spelled out. +7. **What to report**: branch and commits, what changed per ticket, *what could not be verified and why*, and proposed repo-map/`kb.pl` text. Ask explicitly for the thing the agent learned that the ticket did not predict — that is consistently the most valuable part of a hand-back, and agents omit it unless From afba3fa0a6fd7a831c8888f8040a58a8c87283ee Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:50:48 -0700 Subject: [PATCH 03/11] WIP improvise-2bac: interactive arm is parse-then-contextualize Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../improvise-command/src/command/cmd/core.rs | 32 +- .../src/command/cmd/panel.rs | 20 +- .../src/command/cmd/registry.rs | 314 +++++++----------- tests/headless_cli.rs | 79 +++++ 4 files changed, 238 insertions(+), 207 deletions(-) diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index 4af2470f..491718cb 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -139,9 +139,12 @@ pub trait Cmd: Debug + Send + Sync { /// Factory that constructs a Cmd from text arguments (headless/script). pub type ParseFn = fn(&[String]) -> Result, String>; -/// Factory constructing a Cmd from the interactive context. It takes both the keymap -/// args and the context, so a command can combine text args with runtime state. -pub type InteractiveFn = fn(&[String], &CmdContext) -> Result, String>; +/// Fills the context-only fields of a command `parse` already built. It never +/// sees the args, so it cannot discard them — `context/code/cli.md` §8. +pub type ContextualizeFn = fn(C, &CmdContext) -> C; + +/// Builds a command from context, reached ONLY for args `parse` refused. +pub type FallbackFn = fn(&[String], &CmdContext) -> Result; type BoxParseFn = Box Result, String>>; type BoxInteractiveFn = Box Result, String>>; @@ -183,13 +186,26 @@ impl CmdRegistry { name } - /// Register a command with both a text parser and an interactive constructor. - /// The name is derived from a prototype command instance. - pub fn register(&mut self, prototype: &dyn Cmd, parse: ParseFn, interactive: InteractiveFn) { + /// Register a command whose interactive arm is `parse` then `contextualize`, so + /// args `parse` accepts are honoured whole — `context/code/cli.md` §8. + pub fn register( + &mut self, + prototype: &C, + parse: fn(&[String]) -> Result, + contextualize: ContextualizeFn, + fallback: Option>, + ) { self.entries.push(CmdEntry { name: prototype.name(), - parse: Box::new(parse), - interactive: Box::new(interactive), + parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box)), + interactive: Box::new(move |args, ctx| { + let built = match (parse(args), fallback) { + (Ok(cmd), _) => Ok(contextualize(cmd, ctx)), + (Err(_), Some(fallback)) => fallback(args, ctx), + (Err(err), None) => Err(err), + }; + built.map(|c| Box::new(c) as Box) + }), }); } diff --git a/crates/improvise-command/src/command/cmd/panel.rs b/crates/improvise-command/src/command/cmd/panel.rs index 29d1153d..1d27a42f 100644 --- a/crates/improvise-command/src/command/cmd/panel.rs +++ b/crates/improvise-command/src/command/cmd/panel.rs @@ -20,8 +20,7 @@ mod tests { let ctx = make_ctx(&m, &layout, ®); let cmd = TogglePanelAndFocus { panel: effect::Panel::Formula, - open: true, - focused: true, + target: Some((true, true)), }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); @@ -40,8 +39,7 @@ mod tests { let ctx = make_ctx(&m, &layout, ®); let cmd = TogglePanelAndFocus { panel: effect::Panel::Formula, - open: false, - focused: false, + target: Some((false, false)), }; let effects = cmd.execute(&ctx); assert_eq!(effects.len(), 2); @@ -531,20 +529,24 @@ mod tests { #[derive(Debug)] pub struct TogglePanelAndFocus { pub panel: Panel, - pub open: bool, - pub focused: bool, + /// Explicit `(open, focused)`; `None` toggles: close if open and focused, else open and focus. + pub target: Option<(bool, bool)>, } impl Cmd for TogglePanelAndFocus { fn name(&self) -> &'static str { "toggle-panel-and-focus" } - fn execute(&self, _ctx: &CmdContext) -> Vec> { + fn execute(&self, ctx: &CmdContext) -> Vec> { + let (open, focused) = self.target.unwrap_or_else(|| { + let shown = ctx.panel_open(self.panel) && ctx.mode.panel() == Some(self.panel); + (!shown, !shown) + }); let mut effects: Vec> = Vec::new(); effects.push(Box::new(effect::SetPanelOpen { panel: self.panel, - open: self.open, + open, })); - if self.focused { + if focused { effects.push(effect::change_mode(self.panel.mode())); } else { effects.push(effect::change_mode(AppMode::Normal)); diff --git a/crates/improvise-command/src/command/cmd/registry.rs b/crates/improvise-command/src/command/cmd/registry.rs index de1d2985..d9f1d47a 100644 --- a/crates/improvise-command/src/command/cmd/registry.rs +++ b/crates/improvise-command/src/command/cmd/registry.rs @@ -22,15 +22,50 @@ use super::text_buffer::*; use super::tile::*; use super::wizard::*; -/// Explicit coordinates win; the cursor is the fallback — `context/code/cli.md`. -fn key_from_args_or_cursor(args: &[String], ctx: &CmdContext) -> Result { - if args.is_empty() { - ctx.cell_key().clone().ok_or("no cell at cursor".into()) - } else { - Ok(parse_cell_key_from_args(args)) +/// The cell under the cursor: what a coordinate command falls back to when given none. +fn cursor_key(ctx: &CmdContext) -> Result { + ctx.cell_key().clone().ok_or("no cell at cursor".into()) +} + +/// A `Move` built from args takes the live cursor as its origin. +fn move_from_cursor(cmd: Move, ctx: &CmdContext) -> Move { + Move { + cursor: CursorState::from_ctx(ctx), + ..cmd } } +/// A commit built from args still advances from the live cursor. +fn commit_from_cursor(cmd: CommitAndAdvance, ctx: &CmdContext) -> CommitAndAdvance { + CommitAndAdvance { + cursor: CursorState::from_ctx(ctx), + ..cmd + } +} + +/// `` alone: commit the edit buffer to the cell under the cursor. +fn commit_at_cursor( + args: &[String], + ctx: &CmdContext, + advance: AdvanceDir, +) -> Result { + let [mode] = args else { + return Err("takes a mode alone, or a mode, a value and coordinates".into()); + }; + Ok(CommitAndAdvance { + edit_mode: parse_mode_name(mode)?, + value: read_buffer(ctx, "edit"), + key: cursor_key(ctx)?, + advance, + cursor: CursorState::from_ctx(ctx), + }) +} + +/// For a command with no context-only fields: `parse`'s result is used as built. +fn as_parsed(cmd: C, _ctx: &CmdContext) -> C { + cmd +} + /// Build the default command registry with all commands. /// Registry names MUST match the `Cmd::name()` return value. pub fn default_registry() -> CmdRegistry { @@ -51,14 +86,12 @@ pub fn default_registry() -> CmdRegistry { if args.is_empty() { return Err("clear-cell requires at least one Cat/Item coordinate".into()); } - Ok(Box::new(ClearCellCommand { + Ok(ClearCellCommand { key: parse_cell_key_from_args(args), - })) - }, - |args, ctx| { - let key = key_from_args_or_cursor(args, ctx)?; - Ok(Box::new(ClearCellCommand { key })) + }) }, + as_parsed, + Some(|_, ctx| Ok(ClearCellCommand { key: cursor_key(ctx)? })), ); r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse); r.register_pure(&FitMeasure::prototype(), FitMeasure::parse); @@ -80,16 +113,8 @@ pub fn default_registry() -> CmdRegistry { r.register_pure(&ExportCmd(vec![]), ExportCmd::parse); r.register_pure(&WriteCmd(vec![]), WriteCmd::parse); r.register_pure(&HelpCmd(vec![]), HelpCmd::parse); - r.register( - &HelpPageNextCmd(vec![]), - HelpPageNextCmd::parse, - |_args, _ctx| Ok(Box::new(HelpPageNextCmd(vec![]))), - ); - r.register( - &HelpPagePrevCmd(vec![]), - HelpPagePrevCmd::parse, - |_args, _ctx| Ok(Box::new(HelpPagePrevCmd(vec![]))), - ); + r.register_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse); + r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse); // ── Navigation (unified Move) ────────────────────────────────────── r.register( @@ -102,22 +127,14 @@ pub fn default_registry() -> CmdRegistry { require_args(args, 2)?; let dr = args[0].parse::().map_err(|e| e.to_string())?; let dc = args[1].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { + Ok(Move { kind: MoveKind::Relative(dr, dc), cursor: CursorState::default(), cmd_name: "move-selection", - })) - }, - |args, ctx| { - require_args(args, 2)?; - let dr = args[0].parse::().map_err(|e| e.to_string())?; - let dc = args[1].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Relative(dr, dc), - cursor: CursorState::from_ctx(ctx), - cmd_name: "move-selection", - })) + }) }, + move_from_cursor, + None, ); // Jump-to-edge commands: first/last row/col macro_rules! reg_jump { @@ -133,7 +150,7 @@ pub fn default_registry() -> CmdRegistry { cmd_name: $name, }, |_| { - Ok(Box::new(Move { + Ok(Move { kind: if $to_end { MoveKind::ToEnd($is_row) } else { @@ -141,19 +158,10 @@ pub fn default_registry() -> CmdRegistry { }, cursor: CursorState::default(), cmd_name: $name, - })) - }, - |_, ctx| { - Ok(Box::new(Move { - kind: if $to_end { - MoveKind::ToEnd($is_row) - } else { - MoveKind::ToStart($is_row) - }, - cursor: CursorState::from_ctx(ctx), - cmd_name: $name, - })) + }) }, + move_from_cursor, + None, ); }; } @@ -170,21 +178,14 @@ pub fn default_registry() -> CmdRegistry { |args| { require_args(args, 1)?; let n = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { + Ok(Move { kind: MoveKind::Relative(n, 0), cursor: CursorState::default(), cmd_name: "scroll-rows", - })) - }, - |args, ctx| { - require_args(args, 1)?; - let n = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Relative(n, 0), - cursor: CursorState::from_ctx(ctx), - cmd_name: "scroll-rows", - })) + }) }, + move_from_cursor, + None, ); r.register( &Move { @@ -195,28 +196,21 @@ pub fn default_registry() -> CmdRegistry { |args| { require_args(args, 1)?; let dir = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { + Ok(Move { kind: MoveKind::Page(dir), cursor: CursorState::default(), cmd_name: "page-scroll", - })) - }, - |args, ctx| { - require_args(args, 1)?; - let dir = args[0].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(Move { - kind: MoveKind::Page(dir), - cursor: CursorState::from_ctx(ctx), - cmd_name: "page-scroll", - })) + }) }, + move_from_cursor, + None, ); r.register( &EnterAdvance { cursor: CursorState::default(), }, |_| { - Ok(Box::new(EnterAdvance { + Ok(EnterAdvance { cursor: CursorState { row: 0, col: 0, @@ -227,13 +221,12 @@ pub fn default_registry() -> CmdRegistry { visible_rows: 20, visible_cols: 8, }, - })) + }) }, - |_, ctx| { - Ok(Box::new(EnterAdvance { - cursor: CursorState::from_ctx(ctx), - })) + |_, ctx| EnterAdvance { + cursor: CursorState::from_ctx(ctx), }, + None, ); // ── Cell operations ────────────────────────────────────────────────── @@ -245,14 +238,12 @@ pub fn default_registry() -> CmdRegistry { if args.is_empty() { return Err("yank requires at least one Cat/Item coordinate".into()); } - Ok(Box::new(YankCell { + Ok(YankCell { key: parse_cell_key_from_args(args), - })) - }, - |args, ctx| { - let key = key_from_args_or_cursor(args, ctx)?; - Ok(Box::new(YankCell { key })) + }) }, + as_parsed, + Some(|_, ctx| Ok(YankCell { key: cursor_key(ctx)? })), ); r.register( &PasteCell { @@ -262,14 +253,12 @@ pub fn default_registry() -> CmdRegistry { if args.is_empty() { return Err("paste requires at least one Cat/Item coordinate".into()); } - Ok(Box::new(PasteCell { + Ok(PasteCell { key: parse_cell_key_from_args(args), - })) - }, - |args, ctx| { - let key = key_from_args_or_cursor(args, ctx)?; - Ok(Box::new(PasteCell { key })) + }) }, + as_parsed, + Some(|_, ctx| Ok(PasteCell { key: cursor_key(ctx)? })), ); // clear-cell is registered above (unified: ctx.cell_key() or explicit coords) @@ -313,21 +302,15 @@ pub fn default_registry() -> CmdRegistry { if args.is_empty() { return Err("drill-into-cell requires Cat/Item coordinates".into()); } - Ok(Box::new(DrillIntoCell { + Ok(DrillIntoCell { key: parse_cell_key_from_args(args), - })) - }, - |args, ctx| { - let key = key_from_args_or_cursor(args, ctx)?; - Ok(Box::new(DrillIntoCell { key })) + }) }, + as_parsed, + Some(|_, ctx| Ok(DrillIntoCell { key: cursor_key(ctx)? })), ); r.register_nullary(|| Box::new(ViewNavigate { forward: false })); - r.register( - &ViewNavigate { forward: true }, - |_| Ok(Box::new(ViewNavigate { forward: true })), - |_, _| Ok(Box::new(ViewNavigate { forward: true })), - ); + r.register_nullary(|| Box::new(ViewNavigate { forward: true })); r.register_pure(&EnterMode(AppMode::Normal), |args| { require_args(args, 1)?; Ok(Box::new(EnterMode(parse_mode_name(&args[0])?))) @@ -345,38 +328,20 @@ pub fn default_registry() -> CmdRegistry { r.register( &TogglePanelAndFocus { panel: Panel::Formula, - open: true, - focused: true, + target: None, }, |args| { // Parse: toggle-panel-and-focus [open] [focused] require_args(args, 1)?; let panel = parse_panel(&args[0])?; - let open = args.get(1).map(|s| s == "true").unwrap_or(true); - let focused = args.get(2).map(|s| s == "true").unwrap_or(open); - Ok(Box::new(TogglePanelAndFocus { - panel, - open, - focused, - })) - }, - |args, ctx| { - require_args(args, 1)?; - let panel = parse_panel(&args[0])?; - // Default interactive: if already open+focused -> close, else open+focus - let currently_open = ctx.panel_open(panel); - let currently_focused = ctx.mode.panel() == Some(panel); - let (open, focused) = if currently_open && currently_focused { - (false, false) // close - } else { - (true, true) // open + focus - }; - Ok(Box::new(TogglePanelAndFocus { - panel, - open, - focused, - })) + let target = args.get(1).map(|s| { + let open = s == "true"; + (open, args.get(2).map(|s| s == "true").unwrap_or(open)) + }); + Ok(TogglePanelAndFocus { panel, target }) }, + as_parsed, + None, ); r.register( &TogglePanelVisibility { @@ -386,20 +351,16 @@ pub fn default_registry() -> CmdRegistry { |args| { require_args(args, 1)?; let panel = parse_panel(&args[0])?; - Ok(Box::new(TogglePanelVisibility { + Ok(TogglePanelVisibility { panel, currently_open: false, - })) + }) }, - |args, ctx| { - require_args(args, 1)?; - let panel = parse_panel(&args[0])?; - let currently_open = ctx.panel_open(panel); - Ok(Box::new(TogglePanelVisibility { - panel, - currently_open, - })) + |cmd, ctx| TogglePanelVisibility { + currently_open: ctx.panel_open(cmd.panel), + ..cmd }, + None, ); r.register( &CyclePanelFocus { @@ -408,19 +369,18 @@ pub fn default_registry() -> CmdRegistry { view_open: false, }, |_| { - Ok(Box::new(CyclePanelFocus { + Ok(CyclePanelFocus { formula_open: false, category_open: false, view_open: false, - })) + }) }, - |_, ctx| { - Ok(Box::new(CyclePanelFocus { - formula_open: ctx.formula_panel_open, - category_open: ctx.category_panel_open, - view_open: ctx.view_panel_open, - })) + |_, ctx| CyclePanelFocus { + formula_open: ctx.formula_panel_open, + category_open: ctx.category_panel_open, + view_open: ctx.view_panel_open, }, + None, ); r.register( &MovePanelCursor { @@ -433,29 +393,26 @@ pub fn default_registry() -> CmdRegistry { require_args(args, 2)?; let panel = parse_panel(&args[0])?; let delta = args[1].parse::().map_err(|e| e.to_string())?; - Ok(Box::new(MovePanelCursor { + Ok(MovePanelCursor { panel, delta, current: 0, max: 0, - })) + }) }, - |args, ctx| { - require_args(args, 2)?; - let panel = parse_panel(&args[0])?; - let delta = args[1].parse::().map_err(|e| e.to_string())?; - let (current, max) = match panel { + |cmd, ctx| { + let (current, max) = match cmd.panel { Panel::Formula => (ctx.formula_cursor, ctx.model.formulas().len()), Panel::Category => (ctx.cat_panel_cursor, ctx.cat_tree_len()), Panel::View => (ctx.view_panel_cursor, ctx.workbook.views.len()), }; - Ok(Box::new(MovePanelCursor { - panel, - delta, + MovePanelCursor { current, max, - })) + ..cmd + } }, + None, ); r.register_nullary(|| Box::new(DeleteFormulaAtCursor)); r.register_nullary(|| Box::new(AddRecordRow)); @@ -490,11 +447,7 @@ pub fn default_registry() -> CmdRegistry { // ── Grid operations ────────────────────────────────────────────────── r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: true })); - r.register( - &ToggleGroupAtCursor { is_row: false }, - |_| Ok(Box::new(ToggleGroupAtCursor { is_row: false })), - |_, _| Ok(Box::new(ToggleGroupAtCursor { is_row: false })), - ); + r.register_nullary(|| Box::new(ToggleGroupAtCursor { is_row: false })); r.register_nullary(|| Box::new(HideSelectedRowItem)); // ── Text buffer ────────────────────────────────────────────────────── @@ -537,27 +490,16 @@ pub fn default_registry() -> CmdRegistry { return Err("commit-cell-edit requires a mode, value, and coords".into()); } let edit_mode = parse_mode_name(&args[0])?; - Ok(Box::new(CommitAndAdvance { + Ok(CommitAndAdvance { key: parse_cell_key_from_args(&args[2..]), value: args[1].clone(), advance: AdvanceDir::Down, cursor: CursorState::default(), edit_mode, - })) - }, - |args, ctx| { - require_args(args, 1)?; - let edit_mode = parse_mode_name(&args[0])?; - let value = read_buffer(ctx, "edit"); - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(CommitAndAdvance { - key, - value, - advance: AdvanceDir::Down, - cursor: CursorState::from_ctx(ctx), - edit_mode, - })) + }) }, + commit_from_cursor, + Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Down)), ); r.register( &CommitAndAdvance { @@ -568,19 +510,8 @@ pub fn default_registry() -> CmdRegistry { edit_mode: AppMode::editing(), }, |_| Err("commit-and-advance-right requires context".into()), - |args, ctx| { - require_args(args, 1)?; - let edit_mode = parse_mode_name(&args[0])?; - let value = read_buffer(ctx, "edit"); - let key = ctx.cell_key().clone().ok_or("no cell at cursor")?; - Ok(Box::new(CommitAndAdvance { - key, - value, - advance: AdvanceDir::Right, - cursor: CursorState::from_ctx(ctx), - edit_mode, - })) - }, + commit_from_cursor, + Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)), ); r.register( &CommitBuffer { @@ -589,13 +520,14 @@ pub fn default_registry() -> CmdRegistry { |args| { require_args(args, 1)?; let kind = parse_commit_kind(&args[0], &AppMode::Normal)?; - Ok(Box::new(CommitBuffer { kind })) + Ok(CommitBuffer { kind }) }, - |args, ctx| { + as_parsed, + Some(|args, ctx| { require_args(args, 1)?; let kind = parse_commit_kind(&args[0], ctx.mode)?; - Ok(Box::new(CommitBuffer { kind })) - }, + Ok(CommitBuffer { kind }) + }), ); r.register_nullary(|| Box::new(CommitExport)); r.register_nullary(|| Box::new(CommitSave)); @@ -880,6 +812,7 @@ mod panel_open_tests { ctx.registry .interactive("toggle-panel-and-focus", &args, &ctx) .expect("toggle-panel-and-focus resolves") + .execute(&ctx) ); assert!( dbg.contains("open: false"), @@ -897,6 +830,7 @@ mod panel_open_tests { ctx.registry .interactive("toggle-panel-and-focus", &args, &ctx) .expect("toggle-panel-and-focus resolves") + .execute(&ctx) ); assert!( dbg.contains("open: true"), diff --git a/tests/headless_cli.rs b/tests/headless_cli.rs index f5ae9bb6..1789f9c6 100644 --- a/tests/headless_cli.rs +++ b/tests/headless_cli.rs @@ -357,3 +357,82 @@ fn an_explicit_coordinate_still_beats_the_cursor() { "the named cell survived: {left:?}" ); } + +/// North=1, South=2, East=3 on the row axis; the cursor starts on North. +fn three_cells(scratch: &Scratch, name: &str) -> PathBuf { + let doc = three_rows(scratch, name); + run_cmd( + &[ + "set-cell 1 Region/North", + "set-cell 2 Region/South", + "set-cell 3 Region/East", + ], + &doc, + ); + assert_eq!(cell_lines(&doc).len(), 3, "the fixture wrote three cells"); + doc +} + +fn value_of(doc: &Path, item: &str) -> Option { + cell_lines(doc) + .into_iter() + .find(|l| l.contains(&format!("Region={item}"))) + .and_then(|l| l.rsplit(" = ").next().map(str::to_string)) +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn commit_cell_edit_writes_the_named_cell_and_spares_the_cursors() { + let scratch = Scratch::new("commit-explicit"); + let doc = three_cells(&scratch, "commit.improv"); + + let out = run_cmd( + &["move-selection 1 0 . commit-cell-edit editing 5 Region/East"], + &doc, + ); + assert!(out.status.success(), "the line failed: {}", stderr_of(&out)); + let cells = cell_lines(&doc); + assert_eq!(value_of(&doc, "East").as_deref(), Some("5"), "{cells:?}"); + assert_eq!( + value_of(&doc, "South").as_deref(), + Some("2"), + "the cell under the cursor was touched: {cells:?}" + ); + assert_eq!( + value_of(&doc, "North").as_deref(), + Some("1"), + "the origin cell was touched: {cells:?}" + ); +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_line_whose_gate_passes_runs_whole() { + let scratch = Scratch::new("commit-atomic"); + let doc = scratch.path("atomic.improv"); + let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5 Foo/x"], &doc); + assert!( + !stderr_of(&out).contains("Parse error"), + "a line the gate accepted was refused part-way: {}", + stderr_of(&out) + ); + let text = std::fs::read_to_string(&doc).expect("the document"); + assert!( + text.lines().any(|l| l.contains("Foo=x") && l.ends_with("= 5")), + "the commit did not land: {text}" + ); +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_malformed_commit_line_is_refused_whole() { + let scratch = Scratch::new("commit-malformed"); + let doc = seeded(&scratch, "malformed.improv"); + let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5"], &doc); + assert!(!out.status.success(), "a malformed line exited 0"); + let text = std::fs::read_to_string(&doc).expect("the document"); + assert!( + !text.contains("Foo"), + "the first half of a refused line landed: {text}" + ); +} From 7cc52bdb1d1c5b6d12e4a774a71f834dbe9d2f2a Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:51:59 -0700 Subject: [PATCH 04/11] fmt-workspaces-test: one fault case per fixture package, root included (improvise-90ea) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- scripts/fmt-workspaces-test.sh | 55 +++++++++++++++++----------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/scripts/fmt-workspaces-test.sh b/scripts/fmt-workspaces-test.sh index 7e2f292d..8ad50dd0 100755 --- a/scripts/fmt-workspaces-test.sh +++ b/scripts/fmt-workspaces-test.sh @@ -76,36 +76,37 @@ run_case() { scaffold run_case "both workspaces clean is a pass" 0 "$work/repo" -# The load-bearing case. `cargo fmt --all` at the outer root cannot reach this -# file, and `cargo fmt -p inner-pkg` from the outer root exits 1 saying `not a -# member of the workspace` -- the usage error this whole script exists to split -# from a real verdict. +# One fault case PER PACKAGE, enumerated from the fixture rather than +# hand-picked: three hand-picked shapes each missed a rival, and the fourth +# missed the root package itself (improvise-90ea). Reasoning: +# context/test-evidence/tooling.md. The enumeration is a find over +# `[package]` manifests, deliberately NOT the script's own `[workspace]` + +# `cargo metadata` discovery, which is the thing under test. scaffold -printf '%s' "$DIRTY" > "$work/repo/nested/src/lib.rs" -if run_case "a fault in the NESTED workspace is reported" 1 "$work/repo"; then - case "$OUT" in - *nested/src/lib.rs*) ok " and the nested file is named" ;; - *) bad " and the nested file is named" "output did not name it: ${OUT:0:300}" ;; - esac +pkg_manifests=() +while IFS= read -r m; do pkg_manifests+=("$m"); done < <( + cd "$work/repo" && grep -rl --include=Cargo.toml '^\[package\]' . | sort) +# An empty or shrunken enumeration would make the loop below vacuous. +if [ "${#pkg_manifests[@]}" = 3 ]; then + ok "the fixture enumerates all three packages (root, member, nested)" +else + bad "the fixture enumerates all three packages" "found: ${pkg_manifests[*]:-none}" fi - -scaffold -printf '%s' "$DIRTY" > "$work/repo/a/src/lib.rs" -if run_case "a fault in the OUTER workspace is reported" 1 "$work/repo"; then - case "$OUT" in - *a/src/lib.rs*) ok " and the outer file is named" ;; - *) bad " and the outer file is named" "output did not name it: ${OUT:0:300}" ;; - esac - # The rival: spelling the per-root check `--all` instead of `-p ` - # walks PAST the nested workspace boundary (measured), so the OUTER file is - # reported a second time under the inner root. Counting is what sees that. - n="$(printf '%s\n' "$OUT" | grep -c 'a/src/lib.rs')" - if [ "$n" = 1 ]; then - ok " and it is named ONCE -- no root reaches past its own boundary" - else - bad " and it is named ONCE" "named $n times; a root is walking past its boundary" +for m in "${pkg_manifests[@]}"; do + rel="$(dirname "${m#./}")/src/lib.rs"; rel="${rel#./}" + scaffold + printf '%s' "$DIRTY" > "$work/repo/$rel" + if run_case "a fault in $rel is reported" 1 "$work/repo"; then + # Counting, not matching: `--all` for `-p` walks PAST the nested boundary + # (measured) and names an outer file a second time under the inner root. + n="$(printf '%s\n' "$OUT" | grep -cF "$work/repo/$rel")" + if [ "$n" = 1 ]; then + ok " and $rel is named exactly once" + else + bad " and $rel is named exactly once" "named $n times; output: ${OUT:0:300}" + fi fi -fi +done # A usage error must NOT wear a formatting failure's exit code. This is the # whole ticket: `cargo fmt -p opfs-contract` from the repo root exits 1. From 334e218c7b40cd47812a90a4d42c9054ae6d5c4d Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:53:18 -0700 Subject: [PATCH 05/11] s8h7: i on an aggregate names drill, in help and in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the TUI, EditOrDrill hands an aggregated cell to DrillIntoCell, so `i` on an aggregate is drill by another door. The help page called it only "Edit current cell", and the browser refused it in words unrelated to `>`'s, so a user could not tell the two were one missing feature. - help.rs: the edit-or-drill row says it drills into an aggregate. - help.rs: Normal-mode Enter was captioned "Edit current cell (same as i)" but is bound to enter-advance alone, which only moves down; the row moves to Cursor movement and says so. - resolve.rs: the Aggregate arm now reads "an aggregate is edited by drilling in — " + not_yet("drill"), the same sentence `>` uses. - context/code/clients.md and the evidence entry reverse the recorded "different sentences on purpose" rationale, which was false about the TUI. - harness/tests/drill.spec.js follows the new wording. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/clients.md | 19 +++++- context/test-evidence/clients.md | 84 ++++++++++++++++++------ crates/improvise-protocol/src/help.rs | 66 ++++++++++++++++++- crates/improvise-protocol/src/resolve.rs | 51 ++++++++------ harness/tests/drill.spec.js | 17 ++--- 5 files changed, 186 insertions(+), 51 deletions(-) diff --git a/context/code/clients.md b/context/code/clients.md index c9daea61..b535916a 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -891,8 +891,23 @@ gotcha 47 asks for — closing e24v has to come to that line and delete it. broadcasts nothing, so it reaches this user and no one else. This argument arrived as `edit_or_drill`'s, i.e. about `i`/`a` on an aggregate, and for a while it read as doctrine for `>` while `>` was still silent. Both doors now - refuse, and **they refuse with different sentences on purpose**: "this key is - not built yet" and "this cell is not yours to write" are different facts. + refuse, and **they name the same missing feature in the same words** — + `not_yet("drill")` — because they reach the same one. In the TUI, + `EditOrDrill` hands an aggregate straight to `DrillIntoCell`, so an aggregate + *is* writable there, by drilling; `i` on it is drill by another door. `i`'s + sentence adds only *why* it drilled rather than edited ("an aggregate is + edited by drilling in — …"), and `>` still says nothing about aggregates, + because it is refused whatever the cell is. + + This **reverses** what this bullet said until improvise-s8h7: that the two + refused with different sentences on purpose, "this key is not built yet" + against "this cell is not yours to write". The second half was false about + the application — nothing refuses a write to an aggregate; the TUI drills — + and the separate wording hid from a browser user that `i` and `>` were one + missing feature. The help page's `edit-or-drill` caption carried the same + error, calling `i` only "Edit current cell"; it now says it drills into an + aggregate, which is true in both front ends because the browser's refusal + names drill. Delivering drill is `improvise-obtu`'s, not this bullet's. - **The commit's write is not spelled again here.** It is `Client::typed_cell`'s, the same one click-to-edit uses, so a browser keyboard commit and a browser mouse commit cannot disagree about what `""` or `"42"` diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index fd39ad65..e130266a 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -1389,28 +1389,74 @@ happened* passes against a page that has stopped listening, so it presses `i` afterwards and requires `#status` to change. Without that, this file would be folklore with a green tick. -### the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons +### an_aggregate_edit_names_the_same_missing_drill_as_the_drill_key -The Rust half of the interim, and the assertion that carries the whole weight. -Two keys on the same aggregate cell must both refuse and must **not** refuse -with the same sentence: `>` says drill is unavailable, `i` says drilling into an -aggregate needs the host. Collapsing them into one message would be the -tempting tidy-up and would lose the distinction between "this key does nothing -here yet" and "this cell is not one you may write". +improvise-s8h7. Until it, this test was +`the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons` and asserted +the opposite: that `i` and `>` on an aggregate must refuse with *different* +sentences, "this key does nothing here yet" against "this cell is not one you +may write". The second half was false about the application. `EditOrDrill` +hands an aggregated cell to `DrillIntoCell`, so in the TUI `i` on an aggregate +**is** drill — nothing refuses the write, it drills — and the browser's +separate wording ("drilling into an aggregate needs the host") hid that `i` and +`>` are one missing feature. The reasoning is `context/code/clients.md`. -**The injection, and why it is not the obvious one.** Replacing `>`'s sentence -with the *aggregate* sentence looks like it should fail on the first assertion — -and does not: that sentence contains both "drill" and "browser", so it clears -the substring check. What fires is the anti-aggregate assertion lower down. The -claim that the second assertion is the load-bearing one was doubted on review, -tested, and confirmed; record it here because the first assertion reads like the -discriminating one and is not. +So the test now asserts four things on one aggregate fixture: `>` names drill +and the browser; `>` does **not** mention aggregates (it is refused whatever the +cell is); `i`'s sentence **contains** `>`'s sentence verbatim; and `i`'s +sentence mentions "aggregate", so it still says why it drilled rather than +edited. The fixture is `client_on(CellId::Aggregate(..))` rather than the old +empty `in_mode` state, because `i` needs a cell under the cursor to reach the +`Aggregate` arm at all — on an empty grid it answers nothing and only `>` was +ever exercised. -The harness sibling (`drill.spec.js`) asserts the same distinction in a browser, -but its `not.toContainText("aggregate")` is **decoration under the only -injection that exists** — Playwright throws at the positive assertion above it -first, so that line is never reached. The Rust assertion is the guard; the -harness one is end-to-end confirmation that the string survives the wire. +**Three injections, each failing at a different assertion.** Run, not reasoned: + +- `i` says only `not_yet("drill")` — the "why" dropped. Fails at the last + assertion, "and `i` must still say why". This is the tempting over-tidy fix. +- `>`'s `declined` subject becomes "drilling into an aggregate". Fails at the + anti-aggregate assertion on `>`. Note that `i` still contains `>`'s *old* + sentence here, because `edit_or_drill` calls `not_yet("drill")` itself rather + than going through `declined` — so the containment assertion alone would not + have caught a `>` that started blaming the cell. +- `i` paraphrases ("… drill needs the host"). Fails at the containment + assertion: the same feature named in different words is exactly what the + ticket reported. + +The harness sibling (`drill.spec.js`) asserts the same wording in a browser. +Its `not.toContainText("aggregate")` on `>` is still **decoration under the +only injection that exists** — Playwright throws at the positive assertion +above it first — so the Rust assertion is the guard and the harness is +end-to-end confirmation that the string survives the wire. + +### the_edit_key_row_says_it_drills_into_an_aggregate + +improvise-s8h7, the help-page half. The `edit-or-drill` row on the Editing page +read "Edit current cell", while `i`/`a` on an aggregate drill in the TUI and +name drill in the browser. The row is found by its **keys** — the labels +`keys_for(Normal, "edit-or-drill", ["editing"])` returns — not by its caption, +and exactly one row must carry them, so a caption fix that also moved or +duplicated the row fails on the `let [..]` pattern rather than passing on +whichever copy was found first. Failed at HEAD on the caption; the injection +"Edit current cell, or drill" (drill named, aggregate not) fails it too, since +`>` is also drill and the row must say *when* `i` drills. + +### only_the_edit_key_is_captioned_as_editing_the_cell + +Found while fixing improvise-s8h7, not predicted by it. The row below the edit +key read `Enter Edit current cell (same as i)`, but Normal-mode Enter is bound +to `enter-advance` alone — not a `Binding::Sequence` — and `EnterAdvance` only +moves the cursor down, wrapping to the next column. The test takes every help +row captioned "Edit current cell" and requires its keys to be a subset of +`edit-or-drill`'s; at HEAD it failed naming `["Enter"]`. The fix moved the row +to the Navigation page's "Cursor movement" section with a caption that says +what it does. + +Its reach is stated rather than implied: it checks the one phrase that was +false. A caption that misdescribes Enter in *other* words passes it: a caption +is prose, and nothing checks it against the command it documents. That is a +neighbour of improvise-wk18, not the same gap — wk18 is about `literal()` rows +whose *keys* go unchecked; here the keys were right and the words were wrong. ### a_view_row_click_focuses_moves_and_switches_to_the_row_clicked diff --git a/crates/improvise-protocol/src/help.rs b/crates/improvise-protocol/src/help.rs index 8143ec92..724a373c 100644 --- a/crates/improvise-protocol/src/help.rs +++ b/crates/improvise-protocol/src/help.rs @@ -439,6 +439,12 @@ fn navigation() -> Vec { Some(HelpLine::Heading("Cursor movement")), Some(HelpLine::Blank), keys(n, "move-selection", None, "Move one cell"), + keys( + n, + "enter-advance", + None, + "Move down, wrapping to the next column", + ), Some(literal("gg", "Jump to first row")), keys(n, "jump-last-row", None, "Jump to last row"), keys(n, "jump-first-col", None, "Jump to first column"), @@ -513,8 +519,12 @@ fn editing() -> Vec { Some(HelpLine::Blank), Some(HelpLine::Heading("Entering edit mode")), Some(HelpLine::Blank), - keys(n, "edit-or-drill", Some(&["editing"]), "Edit current cell"), - keys(n, "enter-advance", None, "Edit current cell (same as i)"), + keys( + n, + "edit-or-drill", + Some(&["editing"]), + "Edit current cell (drill into an aggregate)", + ), keys( e, "clear-buffer", @@ -950,6 +960,58 @@ mod tests { "an undescribed name falls back to itself rather than to silence" ); } + + fn rows() -> Vec<(Vec, &'static str)> { + pages() + .into_iter() + .flat_map(|page| page.lines) + .filter_map(|line| match line { + HelpLine::Keys { keys, description } => Some((keys, description)), + _ => None, + }) + .collect() + } + + fn labels_of(command: &str, args: Option<&[String]>) -> Vec { + keys_for(&ModeKey::Normal, command, args) + .iter() + .map(|(c, m)| key_label(c, *m)) + .collect() + } + + /// improvise-s8h7: `i` on an aggregate drills in the TUI, so the row + /// that documents it must say so. See `context/test-evidence/clients.md`. + #[test] + fn the_edit_key_row_says_it_drills_into_an_aggregate() { + let edit = labels_of("edit-or-drill", Some(&["editing".to_string()])); + let found: Vec<_> = rows().into_iter().filter(|(k, _)| *k == edit).collect(); + let [(_, description)] = &found[..] else { + panic!("expected one help row keyed {edit:?}, got {found:?}"); + }; + assert!( + description.contains("drill") && description.contains("aggregate"), + "{edit:?} drills into an aggregate and the row must say so: {description}" + ); + } + + /// improvise-s8h7: Normal's Enter is `enter-advance`, which only moves. + /// See `context/test-evidence/clients.md`. + #[test] + fn only_the_edit_key_is_captioned_as_editing_the_cell() { + let edit = labels_of("edit-or-drill", Some(&["editing".to_string()])); + let claims: Vec<_> = rows() + .into_iter() + .filter(|(_, d)| d.contains("Edit current cell")) + .collect(); + assert!(!claims.is_empty(), "the check needs a row to check"); + for (keys, description) in claims { + assert!( + keys.iter().all(|k| edit.contains(k)), + "{keys:?} is captioned {description:?} but only {edit:?} edit" + ); + } + } + /// Not an assertion: a printout for a human to read once. `cargo test -p /// improvise-protocol eyeball -- --nocapture --ignored`. #[test] diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 0bece83a..be549bf3 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -748,9 +748,10 @@ fn edit_or_drill(state: &ClientState, mode: AppMode) -> Option> { }, Command::ChangeMode(mode), ]), - CellId::Aggregate(_) => Some(vec![Command::SetStatus( - "drilling into an aggregate needs the host — not in the browser yet".to_string(), - )]), + CellId::Aggregate(_) => Some(vec![Command::SetStatus(format!( + "an aggregate is edited by drilling in — {}", + not_yet("drill") + ))]), CellId::Derived { target } => Some(vec![Command::SetStatus(format!( "{target} is computed by a formula — edit the formula, not the cell" ))]), @@ -2435,28 +2436,38 @@ mod tests { ); } - /// The advertised key must say what is actually true. Two refusals, two - /// subjects — see `context/test-evidence/clients.md`. + /// `i` on an aggregate IS drill in the TUI, so both doors name one missing + /// feature; only `i` says why. See `context/test-evidence/clients.md`. #[test] - fn the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons() { - let mut state = in_mode(AppMode::Normal); - state.grid.extent = GridExtent { rows: 2, cols: 2 }; - state.window = (2, 2); - - let KeyOutcome::Local(drill) = resolve(&state, ev(KeyCode::Char('>'))) else { - panic!("`>` is advertised in the hint line; it must answer"); - }; - let [Command::SetStatus(said)] = &drill[..] else { - panic!("expected one status, got {drill:?}"); + fn an_aggregate_edit_names_the_same_missing_drill_as_the_drill_key() { + let aggregate = CellId::Aggregate( + serde_json::from_str("\"aa4c8e5e9cbd578e0d388f4b2b5a7879\"").unwrap(), + ); + let state = client_on(aggregate); + let said = |key| match resolve(&state, ev(key)) { + KeyOutcome::Local(commands) => match &commands[..] { + [Command::SetStatus(said)] => said.clone(), + other => panic!("expected one status, got {other:?}"), + }, + other => panic!("{key:?} is advertised; it must answer, got {other:?}"), }; + let (drill, edit) = (said(KeyCode::Char('>')), said(KeyCode::Char('i'))); assert!( - said.contains("drill") && said.contains("browser"), - "the status must name drill and say it is the browser that lacks it: {said}" + drill.contains("drill") && drill.contains("browser"), + "the status must name drill and say it is the browser that lacks it: {drill}" ); assert!( - !said.contains("aggregate"), - "a sentence about aggregates is TRUE-SOUNDING about the wrong thing — \ - `>` is refused whatever the cell is: {said}" + !drill.contains("aggregate"), + "`>` is refused whatever the cell is, so it must not blame the cell: {drill}" + ); + assert!( + edit.contains(&drill), + "`i` reached the same missing feature and must name it the same way: \ + {edit:?} does not contain {drill:?}" + ); + assert!( + edit.contains("aggregate"), + "and `i` must still say why it drilled rather than edited: {edit}" ); } diff --git a/harness/tests/drill.spec.js b/harness/tests/drill.spec.js index d38124c3..c1c12039 100644 --- a/harness/tests/drill.spec.js +++ b/harness/tests/drill.spec.js @@ -36,12 +36,14 @@ async function anAggregateGrid(page) { test.describe("drill is refused in the browser, not broken", () => { // The honest half: `i`/`a` on an aggregate declines OUT LOUD, because // `edit_or_drill` answers the `CellId::Aggregate` arm with a status. - test("editing an aggregate says drill needs the host", async ({ page }) => { + test("editing an aggregate says it is edited by drilling, and drill is missing", async ({ + page, + }) => { await anAggregateGrid(page); await page.keyboard.press("i"); await expect(page.locator("#toast")).toContainText( - "drilling into an aggregate needs the host", + "an aggregate is edited by drilling in — drill is not available in the browser yet", ); // And it really is a refusal: no editor opened, no mode change. await expect(page.locator("#mode")).toHaveText("NORMAL"); @@ -63,16 +65,15 @@ test.describe("drill is refused in the browser, not broken", () => { ); await expect(page.locator("#mode")).toHaveText("NORMAL"); - // The two refusals have two SUBJECTS, and conflating them would be a - // true-sounding sentence about the wrong thing — worse than silence, - // because it stops the reader looking. `>` must not talk about aggregates. + // `>` is refused whatever the cell is, so it must not blame the cell. await expect(page.locator("#toast")).not.toContainText("aggregate"); - // And `i` still gives the OTHER refusal, which is what proves the two are - // distinct rather than one message reached by two keys. + // `i` reaches the SAME missing feature (in the TUI it drills), so it names + // it the same way and adds only why — improvise-s8h7, clients.md. await page.keyboard.press("i"); + await expect(page.locator("#toast")).toContainText("an aggregate is edited by drilling in"); await expect(page.locator("#toast")).toContainText( - "drilling into an aggregate needs the host", + "drill is not available in the browser yet", ); }); }); From 36063da3c98643908196668bf30ad209be02d679 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:53:45 -0700 Subject: [PATCH 06/11] WIP improvise-2bac: tests for explicit args and gate/run agreement Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- .../src/command/cmd/registry.rs | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/crates/improvise-command/src/command/cmd/registry.rs b/crates/improvise-command/src/command/cmd/registry.rs index d9f1d47a..e8f8e04d 100644 --- a/crates/improvise-command/src/command/cmd/registry.rs +++ b/crates/improvise-command/src/command/cmd/registry.rs @@ -573,6 +573,9 @@ mod tests { vec!["1", "1"], vec!["formula", "1"], vec!["editing", "5", "Region", "East"], + vec!["editing", "5", "Region/East"], + vec!["formula", "false", "false"], + vec!["Region/East"], ] .into_iter() .map(|v| v.into_iter().map(String::from).collect()) @@ -734,6 +737,62 @@ mod tests { "only {checked} ex-command examples were found; the scan has stopped seeing them" ); } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor() { + let mut app = crate::ui::app::App::new(two_cat_model(), None); + app.view_state + .buffers + .insert("edit".into(), "decoy".into()); + let ctx = app.cmd_context( + crate::command::key::KeyCode::Null, + crate::command::key::KeyModifiers::NONE, + ); + let target = CellKey::new(vec![ + ("Month".into(), "Feb".into()), + ("Type".into(), "Clothing".into()), + ]); + assert_ne!(ctx.cell_key().as_ref(), Some(&target), "the cursor is elsewhere"); + let args: Vec = ["editing", "5", "Type/Clothing", "Month/Feb"] + .map(String::from) + .into(); + let dbg = format!( + "{:?}", + ctx.registry + .interactive("commit-cell-edit", &args, &ctx) + .expect("commit-cell-edit resolves") + ); + assert!(dbg.contains(&format!("key: {target:?}")), "wrong cell: {dbg}"); + assert!(dbg.contains("value: \"5\""), "wrong value: {dbg}"); + } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn every_argument_list_the_gate_accepts_the_run_accepts_too() { + let r = default_registry(); + let mut wb = crate::workbook::Workbook::new("Empty"); + wb.add_category("Foo").expect("a category with no items"); + let app = crate::ui::app::App::new(wb, None); + let ctx = app.cmd_context( + crate::command::key::KeyCode::Null, + crate::command::key::KeyModifiers::NONE, + ); + assert!(ctx.cell_key().is_none(), "the cursor must sit on no cell"); + let mut accepted = 0; + for name in r.names().collect::>() { + for args in candidate_args() { + if r.parse(name, &args).is_err() { + continue; + } + accepted += 1; + if let Err(e) = r.interactive(name, &args, &ctx) { + panic!("the gate accepts `{name} {args:?}` but the run refuses it: {e}"); + } + } + } + assert!(accepted > 50, "only {accepted} accepted pairs were tried"); + } } #[cfg(test)] @@ -839,4 +898,29 @@ mod panel_open_tests { ); } } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn explicit_open_and_focus_arguments_beat_the_toggle() { + for panel in Panel::ALL { + let mut app = App::new(two_cat_model(), None); + app.view_state.formula_panel_open = false; + app.view_state.category_panel_open = false; + app.view_state.view_panel_open = false; + let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); + let args = [panel.name().to_string(), "false".into(), "false".into()]; + let dbg = format!( + "{:?}", + ctx.registry + .interactive("toggle-panel-and-focus", &args, &ctx) + .expect("toggle-panel-and-focus resolves") + .execute(&ctx) + ); + assert!( + dbg.contains("open: false") && !dbg.contains("open: true"), + "`{} false false` on a closed panel toggled it open: {dbg}", + panel.name() + ); + } + } } From bdf9bff47244f8844e652b03ae906eaa400a3263 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:53:51 -0700 Subject: [PATCH 07/11] The editor overlay and Enter read one buffer (improvise-ktlk) PR #162 (improvise-xg1z) made render.rs paint edit_seed when Editing arrived without its edit buffer, but commit and append-char still read the absent buffer as the empty string. So the overlay showed the value, Enter composed ClearCell, and a typed character replaced the text instead of extending it: two derivations of what is in the editor. The fold now seeds the missing buffer (ClientState::settle_editor, next to settle_mode), and render.rs reads the buffer through read_buffer, as commit does. edit_seed goes back to pub(crate) and no renderer calls it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/clients.md | 58 ++++++++++++++++++---- crates/improvise-protocol/src/client.rs | 18 +++++++ crates/improvise-protocol/src/lib.rs | 2 +- crates/improvise-protocol/src/resolve.rs | 58 ++++++++++++++++++++-- crates/improvise-wasm-client/src/render.rs | 26 +++++----- 5 files changed, 132 insertions(+), 30 deletions(-) diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index fd39ad65..db4787d9 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -4041,17 +4041,18 @@ Demonstrated at `e7de19d`: the first test below reports `Some("")`. ### `an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank` -The fallback is `improvise_protocol::edit_seed`, which is the **existing** home -of "what an editor opened here starts with" — `seed_at` at the cursor, the -function `enter-advance` already seeds from. A second reconciliation of the -scroll offset inside `render.rs` would have been gotcha 42's own trap, so the -one-line fallback cost one `pub` rather than a copy. +**Superseded in part by improvise-ktlk** (entry at the end of this file). As +first landed, the fallback lived in `render.rs`: `editing` fell back to +`edit_seed` while `commit` and `append-char` still read the absent buffer as +`""`, so the overlay showed `1200` and Enter cleared it. The fallback now lives +in the fold (`ClientState::settle_editor`), which seeds the buffer itself, and +`render.rs` reads the same buffer `commit` does. The test's first assertion +changed with it: it used to check that the buffer was still absent after the +fold, and now checks that the fold seeded it. -The test asserts three things in order, and the middle one is why it is not -vacuous: that the buffer really is absent, that the cell really holds `1200`, -and only then what `editing` says. Without the first two a fixture that happened -to carry an `edit` buffer, or a grid whose cell was blank anyway, would pass -against the bug. +The test asserts three things in order: what the fold put in the buffer, that +the cell really holds `1200`, and only then what `editing` says. Without the +second, a grid whose cell was blank anyway would pass against the bug. **Fault injected:** `unwrap_or_default()`, the original expression. Fails with `Some("")`. @@ -4177,3 +4178,40 @@ and converged. **Fault injected:** the same `Binding::Prefix` → `Upstream` reversion. Fails on the second press. + +## improvise-ktlk — the overlay and the commit read one buffer + +PR #162 (improvise-xg1z) changed what the overlay **draws** for an `Editing` +mode that arrived without its `edit` buffer, and not what Enter **commits**. +`render.rs` painted `edit_seed(state)`, while `resolve.rs::commit` and +`append-char` read `read_buffer(&state.view, "edit")`, which is `""` for an +absent key. So the overlay showed the value, Enter composed `ClearCell`, and one +typed character replaced the shown text rather than extending it. #162's tests +only looked at the drawing, and none of them pressed a key. + +The fix gives the fact one home. The fold seeds the missing buffer +(`ClientState::settle_editor`, called after every fold, bootstrap and refusal, +beside `settle_mode`), and `render.rs` reads the buffer through `read_buffer`, +as `commit` does. `edit_seed` is `pub(crate)` again and no renderer calls it. +Seeding happens only when there is a cell under the cursor that has +`edit_text`. With no such cell the buffer stays absent, which reads as `""` +on both paths, and `commit` declines anyway for want of a cell. + +### `an_editor_entered_without_its_buffer_commits_and_extends_the_cell` + +The fixture's display text (`1,200.50`) and edit text (`1200.5`) **differ**. +With `CellDisplay::number("1200")`, as #162 used, a fallback that seeds from the +formatted `text` passes too (gotcha 8). The test folds `ChangeMode(editing)` +with no `SetBuffer`, then presses the keys: Enter must compose +`SetCell(Number(1200.5))`, and after a typed `5`, Enter must compose +`SetCell(Number(1200.55))`. + +**Faults injected, each run:** +- The tree before this fix (render-only fallback). The buffer assertion fails + with `""`. With that assertion removed, the Enter assertion fails on its own + with `None`, because the first command is `ClearCell`. +- `edit_seed` returning the drawn `text` instead of `edit_text`. Fails with + `"1,200.50"`. +- The over-broad seed, "absent **or empty**, refill". This test passes, and + `an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell` fails, + as it did against the same rival in `render.rs`. diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index 8010e920..599f21e4 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -74,6 +74,7 @@ impl ClientState { self.apply_projection(projection); } self.settle_mode(); + self.settle_editor(); self.revision += 1; } @@ -85,6 +86,7 @@ impl ClientState { other => reduce_view(&mut self.view, other), } self.settle_mode(); + self.settle_editor(); self.revision += 1; } @@ -124,6 +126,7 @@ impl ClientState { } self.last_rejection = Some(rejection); self.settle_mode(); + self.settle_editor(); self.revision += 1; } @@ -158,6 +161,21 @@ impl ClientState { self.view.mode = settled_mode(&self.view.mode, self.grid.records); } + /// An editor that arrived without its buffer is seeded here, so what is + /// drawn and what Enter commits read one buffer (improvise-ktlk). + fn settle_editor(&mut self) { + let absent = self + .view + .mode + .minibuffer() + .filter(|_| self.view.mode.is_editing()) + .map(|mb| mb.buffer_key.clone()) + .filter(|key| !self.view.buffers.contains_key(key)); + if let (Some(key), Some(seed)) = (absent, crate::resolve::edit_seed(self)) { + self.view.buffers.insert(key, seed); + } + } + /// The widget dispatch: a projection names the widget it is addressed to. /// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32). fn apply_projection(&mut self, projection: &Projection) { diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 53544924..8abc5ec4 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -34,7 +34,7 @@ pub use render::{ WizardProposal, WizardStepKind, header_level_widths, }; pub use resolve::{ - Click, KeyOutcome, Request, click, edit_seed, read_buffer, resolve, typed, typed_cell_command, + Click, KeyOutcome, Request, click, read_buffer, resolve, typed, typed_cell_command, }; pub use viewmodel::{CatTreeEntry, flatten_cat_tree, view_axis_summary}; // `improvise-core`'s, because the index that resolves one lives in diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 0bece83a..b7647270 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -954,11 +954,10 @@ fn record_at_cursor(state: &ClientState) -> Option { } } -/// What an editor opened on the cursor starts with. The one home, so a -/// renderer does not reconcile the scroll offset again (gotcha 42). -pub fn edit_seed(state: &ClientState) -> String { - let (row, col) = state.view.cursor().selected; - seed_at(state, row, col) +/// What an editor opened on the cursor starts with, or `None` with no cell +/// there to seed from. Read by the fold, never a renderer (improvise-ktlk). +pub(crate) fn edit_seed(state: &ClientState) -> Option { + cell_at_cursor(state)?.edit_text.clone() } /// The display text at an absolute grid position, as the cache holds it. @@ -3017,6 +3016,55 @@ mod tests { )); } + /// improvise-ktlk: what an editor shows and what Enter commits are one + /// fact. See `context/test-evidence/clients.md`. + #[test] + fn an_editor_entered_without_its_buffer_commits_and_extends_the_cell() { + let mut client = client_on(record_cell()); + let mut cell = CellDisplay::number("1,200.50"); + cell.edit_text = Some("1200.5".into()); + cell.id = Some(record_cell()); + client.fold(&Command::Project(Projection::Grid(GridUpdate::Cells( + vec![((0, 0), cell)], + )))); + client.fold(&Command::ChangeMode(AppMode::editing())); + assert_eq!( + read_buffer(&client.view, "edit"), + "1200.5", + "the buffer is what the overlay paints and what a commit reads" + ); + + let committed = |client: &ClientState| { + let KeyOutcome::Local(commands) = resolve(client, ev(KeyCode::Enter)) else { + panic!("Enter in Editing must resolve here"); + }; + match &commands[0] { + Command::Model { + cmd: ModelCommand::SetCell { value, .. }, + .. + } => Some(value.clone()), + _ => None, + } + }; + assert_eq!( + committed(&client), + Some(CellValue::Number(1200.5)), + "Enter on an editor showing the cell must not clear it" + ); + + let KeyOutcome::Local(typed) = resolve(&client, ev(KeyCode::Char('5'))) else { + panic!("a character in Editing is append-char"); + }; + for command in &typed { + client.fold(command); + } + assert_eq!( + committed(&client), + Some(CellValue::Number(1200.55)), + "a typed character extends what the editor showed, not replaces it" + ); + } + /// At the bottom-right corner `EnterAdvance` returns `AbortChain`, so the /// commit leaves the user in normal mode. See the evidence file. #[test] diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index b73785d0..8025d36b 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -294,16 +294,13 @@ pub fn render_model(state: &ClientState) -> RenderModel { help: help_rows(&state.view), help_page: state.view.help_page, help_titles: help_titles(&state.view), - // An ABSENT buffer is not an empty one: blanking the overlay over a - // real value is one Enter from clearing it (improvise-xg1z). - editing: state.view.mode.is_editing().then(|| { - state - .view - .buffers - .get("edit") - .cloned() - .unwrap_or_else(|| improvise_protocol::edit_seed(state)) - }), + // The buffer Enter commits, never a second derivation of it: the fold + // seeds an absent one (improvise-xg1z, improvise-ktlk). + editing: state + .view + .mode + .is_editing() + .then(|| improvise_protocol::read_buffer(&state.view, "edit")), minibuffer: state.view.mode.minibuffer().map(|mb| RenderMinibuffer { prompt: mb.prompt.clone(), value: improvise_protocol::read_buffer(&state.view, &mb.buffer_key), @@ -912,10 +909,11 @@ mod tests { client .state_mut() .fold(&Command::ChangeMode(improvise_protocol::AppMode::editing())); - assert!( - !client.state().view.buffers.contains_key("edit"), - "the fixture must really be missing the buffer, or this proves \ - nothing about the fallback" + assert_eq!( + client.state().view.buffers.get("edit").map(String::as_str), + Some("1200"), + "the fold seeds the missing buffer, so the overlay and a commit \ + read one thing (improvise-ktlk)" ); assert_eq!( client.model().rows[0].cells()[0].text, From 808cbb7d92e9920ccba616612faed0e111596633 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:55:25 -0700 Subject: [PATCH 08/11] fmt-workspaces: refuse a package no workspace root reaches (improvise-90ea) The fixture loop covers every package the fixture has; this covers every package the tree has. The review's skip-the-root rival now fails the real repo's own gate (exit 2 naming Cargo.toml), not only the test suite. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 63 ++++++++++++++++++++++++++++++++ scripts/fmt-workspaces-test.sh | 26 +++++++++++++ scripts/fmt-workspaces.sh | 59 +++++++++++++++++++++--------- 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index 5d2b0a1e..bda78aae 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -391,6 +391,8 @@ green suite over a vacuous assertion. **What is not covered.** The script asks `cargo metadata` for members, so a root whose metadata succeeds but whose member list is wrong is not detectable here. +*Narrowed by improvise-90ea, below:* a list that **omits** a package in the tree +is now refused; one that names a package it should not is still not detected. And `harness/opfs-contract`'s own `cargo fmt -p opfs-contract` step in the `harness-lint` job is left as it is: it resolves correctly because that step sets `working-directory`, and the ticket's own note confirms CI as written was @@ -438,3 +440,64 @@ same way: the suite passed *under a rival*. The generalisable rule is that a stub must fail in the **shape** of the real fault, not merely fail — a stub that breaks more than the real thing breaks satisfies the test through the wrong arm, and reads exactly like coverage. + +#### The root package, and why the fourth fix is structural — `scripts/fmt-workspaces-test.sh` + +A post-merge review of PR #159 found a **fourth** fixture vacuity +(improvise-90ea). The fixture's outer root is a package (`root-pkg`) as well as +a workspace, which is right, but no case ever dirtied **the root package's own +`src/lib.rs`**. Only the member `a/` and `nested/` were dirtied. So a script +that formats every member *except* the root package passed 17/17. In this repo +the root package is `improvise`, the whole TUI. Reproduced at `40b8913` with +the review's rival in `members_of` (drop the package whose manifest sits at +`workspace_root`): the suite passed 17/17 and `--list` left `improvise` out. + +Adding one more hand-picked case would have fixed the fourth and not the fifth. +Four vacuities in one checker all have the same cause: **the cases were chosen +by hand, one per hazard somebody had thought of.** So the fix is structural, +and it has two halves because it has to cover two sets of packages: + +- **Every package the fixture has.** The two hand-picked fault cases are + replaced by a loop over every `[package]` manifest in the fixture. Each one + gets its own misformatted `src/lib.rs`, must exit 1, and must be named + **exactly once**, so the `--all` boundary check now covers every package + rather than one. The enumeration is a `grep` for `[package]`, deliberately + *not* the script's `[workspace]` + `cargo metadata` discovery, since that + discovery is what is under test. A count assertion (three packages) stops + the loop going vacuous if the fixture loses a package. A package added to + the fixture gets its case automatically. +- **Every package the tree has.** The loop proves only that the script handles + the fixture's *shapes*. A real tree can hold a package no root lists (an + `exclude`d crate, say), and nothing in the fixture would say so. So the gate + itself now checks an invariant **before it formats anything**: every + `[package]` manifest the find reaches must be a member of some root, or it + exits **2** and names the package. That is exit 2 and not 1 because no + verdict was produced for that package, which is what exit 2 means here. + Paths are compared through `realpath`, because on macOS `mktemp` lives under + `/var` while cargo reports `/private/var`. + +**Rivals injected, and where each failed** (the suite went from 17 to 21 cases): + +- **The review's skip-the-root rival.** It now fails the fixture at `a fault in + src/lib.rs is reported` (exit 0). With the invariant in place it fails + **six** cases (exit 2, "no workspace root reaches"). It also fails the + **real repo** with no fixture involved: `fmt-workspaces.sh` exits 2 naming + `Cargo.toml`. That last one is the property worth having. The gate refuses + its own blindness in CI, not only in its test suite. +- **Disabling the invariant** (`if false`). It fails `a package no workspace + root reaches is exit 2, not a pass` with **exit 0**, even though that + package's `src/lib.rs` is misformatted. That exit 0 *is* the hazard: an + unreached package is silently clean. +- **The two earlier rivals, re-run against the loop.** `--all` for `-p` fails + both `a/src/lib.rs` and `src/lib.rs` "named exactly once". The second of + those was not predicted: the nested root's `--all` also reaches the root + package. The bare manifest-path form fails `a fault in a/src/lib.rs is + reported`. + +The real-repo control from the ticket still holds: a misformatted fn appended +to `src/ui/text.rs` is reported (`Diff in …/src/ui/text.rs:72`). + +**What is still not covered.** A root whose member list *includes* something it +should not is not detected, and a package in a directory the find excludes +(`target/`, `.claude/`, `node_modules/`) is outside the invariant by +construction. diff --git a/scripts/fmt-workspaces-test.sh b/scripts/fmt-workspaces-test.sh index 8ad50dd0..ce15d6ce 100755 --- a/scripts/fmt-workspaces-test.sh +++ b/scripts/fmt-workspaces-test.sh @@ -122,6 +122,32 @@ run_case "a tree with no workspace root is a usage error, not a pass" 2 "$work/e run_case "--help is a usage error, so a typo cannot pass" 2 "$work/repo" --help +# The loop above covers every package the FIXTURE has; this covers every +# package the TREE has. A package no root lists is checked by nothing, so the +# gate must refuse a verdict -- and name the package -- rather than pass. +scaffold +mkdir -p "$work/repo/orphan/src" +printf '[package]\nname = "orphan-pkg"\nversion = "0.1.0"\nedition = "2021"\n' \ + > "$work/repo/orphan/Cargo.toml" +printf '%s' "$DIRTY" > "$work/repo/orphan/src/lib.rs" +cat > "$work/repo/Cargo.toml" <<'EOF' +[workspace] +members = ["a"] +exclude = ["orphan"] +resolver = "2" + +[package] +name = "root-pkg" +version = "0.1.0" +edition = "2021" +EOF +if run_case "a package no workspace root reaches is exit 2, not a pass" 2 "$work/repo"; then + case "$OUT" in + *"no workspace root reaches"*orphan/Cargo.toml*) ok " and the unreached package is named" ;; + *) bad " and the unreached package is named" "output: ${OUT:0:300}" ;; + esac +fi + # The narrowed contract, pinned so it is not merely prose (ChatGPT Codex on # PR #159). rustfmt exits 1 for a source it cannot parse, exactly as for a # diff -- so exit 1 is "rustfmt did not certify this root clean", and what diff --git a/scripts/fmt-workspaces.sh b/scripts/fmt-workspaces.sh index abb6fec9..982d2211 100755 --- a/scripts/fmt-workspaces.sh +++ b/scripts/fmt-workspaces.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Format-checks every Cargo workspace root in the repo, from any directory. -# `cargo fmt --all` reaches only ONE of them. Reasoning: context/code/tooling.md -# section 4 -- including why `-p` scoped by `--manifest-path` is the only +# `cargo fmt --all` reaches only ONE of them. Reasoning: context/test-evidence/ +# tooling.md, "The two-workspace fmt gate" -- including why `-p` scoped by `--manifest-path` is the only # spelling that is neither blind nor boundary-crossing (improvise-b4ks). set -uo pipefail @@ -18,8 +18,8 @@ exit 1 rustfmt did not certify a root clean: a file differs from rustfmt's output, or rustfmt refused to parse one. Either way a FILE IS NAMED and the tree is what is wrong. exit 2 the gate did not run: usage error, no cargo, no rustfmt, cargo - metadata failed, or no [workspace] root found. NO verdict was - produced and NO file is named. + metadata failed, no [workspace] root found, or a [package] that no + root has as a member. NO verdict was produced and NO file is named. The split is "did the gate run", not "is every exit-1 cause a diff". That is narrower than it sounds and it is deliberate -- see the note on rc=1 below. @@ -38,8 +38,8 @@ EOF # The find is RELATIVE to the tree being scanned: an absolute `-path` filter # matches the ancestors too, and this repo is itself checked out under # `.claude/worktrees/` in every agent worktree, where that excluded everything. -workspace_roots() { - local root="$1" +manifests_with() { + local root="$1" section="$2" ( cd "$root" && find . -name Cargo.toml \ -not -path './target/*' -not -path '*/target/*' \ -not -path './.git/*' \ @@ -47,11 +47,12 @@ workspace_roots() { -not -path './.claude/*' ) \ | sort \ | while IFS= read -r rel; do - if grep -q '^\[workspace\]' "$root/${rel#./}"; then + if grep -q "^\\[$section\\]" "$root/${rel#./}"; then printf '%s\n' "$root/${rel#./}" fi done } +workspace_roots() { manifests_with "$1" workspace; } # The members of ONE workspace, named by cargo rather than parsed out of the # manifest: a `members = [...]` list may carry globs and path dependencies. @@ -61,10 +62,10 @@ members_of() { printf '%s' "$meta" | python3 -c ' import json, sys doc = json.load(sys.stdin) -names = [p["name"] for p in doc["packages"]] -if not names: +rows = [p["name"] + "\t" + p["manifest_path"] for p in doc["packages"]] +if not rows: sys.exit(1) -print("\n".join(names)) +print("\n".join(rows)) ' || return 1 } @@ -106,15 +107,39 @@ main() { return 2 fi - local rc=0 manifest member - for manifest in "${roots[@]}"; do - local -a pkgs=() - while IFS= read -r member; do [ -n "$member" ] && pkgs+=("$member"); done \ - < <(members_of "$manifest") - if [ ${#pkgs[@]} -eq 0 ]; then - printf 'fmt-workspaces: cargo metadata failed for %s\n' "$manifest" >&2 + # Pass 1: every root's members, before anything is formatted, so that a + # package NO root reaches is exit 2 rather than silently never checked. + local rc=0 manifest member i m + local -a members=() covered=() + for i in "${!roots[@]}"; do + local rows + rows="$(members_of "${roots[$i]}")" || rows="" + if [ -z "$rows" ]; then + printf 'fmt-workspaces: cargo metadata failed for %s\n' "${roots[$i]}" >&2 return 2 fi + members[$i]="$(printf '%s\n' "$rows" | cut -f1 | tr '\n' ' ')" + while IFS= read -r m; do covered+=("$m"); done < <(printf '%s\n' "$rows" | cut -f2) + done + local orphans + orphans="$(manifests_with "$root" package | python3 -c ' +import os, sys +covered = {os.path.realpath(p) for p in sys.argv[1:]} +for line in sys.stdin: + m = line.rstrip("\n") + if m and os.path.realpath(m) not in covered: + print(m) +' "${covered[@]}")" + if [ -n "$orphans" ]; then + printf 'fmt-workspaces: no workspace root reaches these packages, so nothing checks them:\n%s\n' \ + "$orphans" >&2 + return 2 + fi + + for i in "${!roots[@]}"; do + manifest="${roots[$i]}" + local -a pkgs=() + read -r -a pkgs <<< "${members[$i]}" if [ "$list" = 1 ]; then printf '%s: %s\n' "$manifest" "${pkgs[*]}" From d9b10905d0eaad361391af7d89f3b501b18dae07 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 20:58:34 -0700 Subject: [PATCH 09/11] improvise-2bac: context docs, evidence, fmt Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/code/cli.md | 68 +++++++++++++++ context/code/command.md | 13 +-- context/test-evidence/cli.md | 86 ++++++++++++++++++- .../src/command/cmd/registry.rs | 39 +++++++-- tests/headless_cli.rs | 8 +- 5 files changed, 195 insertions(+), 19 deletions(-) diff --git a/context/code/cli.md b/context/code/cli.md index e1c20ca3..d4cc9a68 100644 --- a/context/code/cli.md +++ b/context/code/cli.md @@ -151,7 +151,75 @@ atomicity is preserved by keeping the existing whole-line `parse_line` as a still parses, and a line that did not still does not, so the only behavioural change is which cursor the commands see. +**That atomicity claim was false as written** (improvise-2bac), and §8 is what +makes it true. The gate runs the pure arm; the loop runs the interactive arm. A +gate is only a gate if *every argument list the pure arm accepts, the +interactive arm accepts too* — and `commit-cell-edit` broke it: +`add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document +passed the gate, applied the first segment, and then refused the second with +`no cell at cursor`. Half the line landed. + The TUI's `:` line had the same defect and is fixed the same way, with one limitation that is not new: `ExecuteCommand` returns one effect batch, so the segments of `:a . b` all see the pre-batch cursor, exactly as a `Binding::Sequence` does (gotchas 21 and 23). + +## 8 — the interactive arm is `parse`, then context; it is never a second parser + +#151 fixed three instances of *a registry with two construction arms demotes +whichever caller reaches the weaker one*, one patch each. A post-merge review +found the fourth (improvise-2bac): `commit-cell-edit `, +whose interactive arm read only ``, took the value from the `edit` buffer +and the key from the cursor. So a script that wrote 5 to `Region/East` +**deleted the cell under the cursor** — the empty buffer, committed — with exit +0 and nothing on stderr. The fifth was already in the tree: +`toggle-panel-and-focus false false` ignored both flags and toggled. + +The shape rather than the instances is what changed. `CmdRegistry::register` +used to take two free functions, `parse(args)` and `interactive(args, ctx)`, +and nothing related them; each command re-derived its arguments twice and every +disagreement was silent. It now takes three pieces, and the interactive arm is +**built from `parse`**, not beside it: + +- `parse(args) -> C` — the only function that reads arguments a caller supplied. +- `contextualize(C, &CmdContext) -> C` — fills the fields that are context and + never argument (a `Move`'s origin cursor, a `MovePanelCursor`'s bound). It is + handed the built command and **not the args**, so it cannot discard them. +- `fallback: Option<(args, ctx) -> C>` — reached **only** when `parse` refused. + This is where "no coordinates means the cursor" lives: `clear-cell` bare, and + `commit-cell-edit ` bare, which is what the keymap binds. + +Two consequences hold by construction rather than by test. **Whatever `parse` +accepts, the interactive arm accepts** — `contextualize` is infallible — so the +§7 gate cannot pass a segment the loop then refuses. And **an accepted argument +list cannot be overridden by the cursor**, because the only function that sees +both args and context never runs on an accepted list. `register_pure` and +`register_nullary` already had one arm; they are the `contextualize = identity, +fallback = None` case. + +What is still representable, stated rather than hidden: + +- `contextualize` can overwrite a field `parse` set. It would have to name the + field, which is a visible line in review, where the old `|_args, ctx|` was an + invisible omission. `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor` + fails if `commit-cell-edit`'s does. +- A `fallback` sees the args `parse` refused and could ignore some of them. + `commit-cell-edit`'s refuses anything but a lone mode, so the malformed + `commit-cell-edit editing 5` is an error on the `:` line — which has no pure + gate — rather than a commit of the edit buffer to the cursor cell. +- The gate is **stricter** than the loop wherever a fallback exists: a bare + `clear-cell` is refused in a script. That is the safe direction — the whole + line is refused — and it is unchanged from before #151. + +`toggle-panel-and-focus` did not fit `contextualize`, because its pure arm +defaulted the omitted flags to `true` and a contextualizer cannot tell an +omitted flag from an explicit one. So the omission is now in the type — +`target: Option<(bool, bool)>`, `None` meaning *toggle* — and the toggle is +resolved in `execute` against the same context. That is the honest home: the +decision depends on context, and `execute` is where context is. + +Guards: `every_argument_list_the_gate_accepts_the_run_accepts_too` enumerates +**every registration** against every candidate argument list, in a context whose +cursor sits on no cell — the context in which the old `commit-cell-edit` arm +refused. It lists no commands, so a new registration is covered the day it +lands. It is backed by the process-level tests in `tests/headless_cli.rs`. diff --git a/context/code/command.md b/context/code/command.md index 02de2469..987df348 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -2489,12 +2489,13 @@ line separated by `.`, coordinate pairs written `Category/Item`, and quoted stri available for the rare command that needs to enumerate all views, such as the view panel. -`InteractiveFn` constructs a `Cmd` from the interactive context (keymap dispatch). -It receives both the keymap args and the interactive context, so commands can -combine text arguments (a panel name) with runtime state (whether that panel is -currently open). `register_pure` registers a command that does not need interactive -context: called interactively with args it delegates to `parse`, and without args it -errors. +The interactive arm (keymap dispatch, scripts, the `:` line) is built **from** +`parse`: `register` takes `parse`, a `contextualize` that fills context-only +fields of the command `parse` built without seeing the args, and an optional +`fallback` reached only for argument lists `parse` refused. So an accepted +argument list is never overridden by context (improvise-2bac; the argument is +`context/code/cli.md` §8). `register_pure` is the case with nothing to +contextualize and no fallback: both arms are `parse`. `write_buffer` is the effect that writes a named buffer — the mirror of `read_buffer`, and the only place that knows "search" is not an entry in the buffers diff --git a/context/test-evidence/cli.md b/context/test-evidence/cli.md index bf5f7818..4c89ddee 100644 --- a/context/test-evidence/cli.md +++ b/context/test-evidence/cli.md @@ -229,12 +229,94 @@ the syntax gate and then cleared whatever the cursor was sitting on — the orig So improvise-6uva's own lesson had a **third** instance in its own fix: two construction arms that mean different things demote whichever caller reaches the weaker one, and here the "weaker" one was the arm that had the context. The four -arms now go through `key_from_args_or_cursor`: explicit coordinates win, the +arms went through `key_from_args_or_cursor`: explicit coordinates win, the cursor is the fallback, which is also what the keymap path wants since a binding -carries no arguments. +carries no arguments. That helper is gone since improvise-2bac: the cursor is +now the registry's `fallback`, reached only when `parse` refused, so explicit +coordinates win by construction (`context/code/cli.md` §8). The fixture writes three cells and clears the one the cursor is **not** on, so the assertion separates "cleared the named cell" from "cleared the cursor cell" — clearing `North` by name would have passed against the broken code. Restoring the arg-discarding arm fails it: `the cursor cell was cleared instead of the named one: ["Region=East = 3", "Region=South = 2"]`. + +### commit_cell_edit_writes_the_named_cell_and_spares_the_cursors + +improvise-2bac, found by the post-merge adversarial review of PR #151: the +fourth instance of that PR's own lesson. `commit-cell-edit` was registered with +two free arms. The pure arm read ` `; the interactive arm +read only ``, took the value from the `edit` buffer and the key from the +cursor. Once #151 made scripts construct through the interactive arm, +`commit-cell-edit editing 5 Region/East` passed the syntax gate and then +committed an **empty** buffer to the cursor cell — deleting it — with exit 0 +and nothing on stderr. + +Reproduced at `40b8913` before the fix, through the real binary: the document +came back as `["Region=East = 3", "Region=North = 1"]` — `South`, the cell the +cursor had been moved onto, was gone and `East` was unchanged. + +The fixture writes three cells and **moves the cursor to `South`** in the same +line before committing to `East`, so the cursor sits on neither the target nor +the origin: a test with the cursor on the target is right by accident, and one +that only asserts the target misses the destruction. It asserts all three. + +Injected rivals, run: (a) `contextualize` for `CommitAndAdvance` overwriting +`key` and `value` from the cursor and the buffer — fails this test and +`a_line_whose_gate_passes_runs_whole`; (b) the registry dispatching to the +fallback first whenever one exists (the pre-fix shape) — fails this test, +`a_line_whose_gate_passes_runs_whole`, `an_explicit_coordinate_still_beats_the_cursor` +and both registry unit tests below. + +### a_line_whose_gate_passes_runs_whole + +improvise-2bac's second symptom, reported on the ticket: `context/code/cli.md` +§7 said the whole-line `parse_line` gate preserved line atomicity. It did not, +wherever the interactive arm refused an argument list the pure arm accepted. +`add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document +printed `Parse error: commit-cell-edit: no cell at cursor` after `Foo` had +already been added and saved: half the line landed. + +The assertion is on `Parse error` and on the committed cell, **not** on the exit +code. The line exits 1 both before #151 and after this fix, for a different and +pre-existing reason: the grid has no rows, so `commit-cell-edit`'s advance +emits `AbortChain`, and the CLI maps an aborted batch to exit 1 +(`context/code/cli.md` §3). Asserting success would have made this test fail on +the correct behaviour. + +### a_malformed_commit_line_is_refused_whole + +The other half of atomicity, which must survive the fix: a segment the gate +refuses (`commit-cell-edit editing 5` — a value and no coordinates) refuses the +whole line, so the preceding `add-category Foo` never lands. It passed before +the fix and must keep passing; its job is to catch a "fix" that loosens the +pure arm to make the gate agree. + +### commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor + +The unit-level form of the headless test, against `CmdRegistry::interactive` +directly: cursor on `Type=Food, Month=Jan`, a decoy `edit` buffer, and an +explicit `5` for `Type/Clothing Month/Feb`. It asserts the built command's key +and value. Rival (a) above — a `contextualize` that overwrites the parsed +fields — fails it; this is the one hole the new registry shape leaves +representable, so it is the one that needs a test. + +### every_argument_list_the_gate_accepts_the_run_accepts_too + +Enumerates **every** registration (`CmdRegistry::names`, no hand-kept list) +against every candidate argument list, and requires the interactive arm to +accept whatever the pure arm accepts, in a context whose cursor sits on **no +cell** — a category with no items. That context is what made the old +`commit-cell-edit` arm refuse, so a context with a cell under the cursor would +have passed the bug. The assertion that the cursor has no cell is checked +first; a plain `Workbook::new` does not satisfy it, which is how the fixture +came to add the empty category. Rival (b) fails it. + +### explicit_open_and_focus_arguments_beat_the_toggle + +The fifth instance, named on improvise-2bac as plausible and not yet run: +`toggle-panel-and-focus false false` ignored both flags and toggled, so +on a **closed** panel it opened it. The fixture closes every panel first, +because on an open-and-focused panel the toggle also closes it and the test +would pass against the bug. Rival: `execute` ignoring `target` and always +toggling — fails this test for every panel. diff --git a/crates/improvise-command/src/command/cmd/registry.rs b/crates/improvise-command/src/command/cmd/registry.rs index e8f8e04d..6ef243dc 100644 --- a/crates/improvise-command/src/command/cmd/registry.rs +++ b/crates/improvise-command/src/command/cmd/registry.rs @@ -91,7 +91,11 @@ pub fn default_registry() -> CmdRegistry { }) }, as_parsed, - Some(|_, ctx| Ok(ClearCellCommand { key: cursor_key(ctx)? })), + Some(|_, ctx| { + Ok(ClearCellCommand { + key: cursor_key(ctx)?, + }) + }), ); r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse); r.register_pure(&FitMeasure::prototype(), FitMeasure::parse); @@ -243,7 +247,11 @@ pub fn default_registry() -> CmdRegistry { }) }, as_parsed, - Some(|_, ctx| Ok(YankCell { key: cursor_key(ctx)? })), + Some(|_, ctx| { + Ok(YankCell { + key: cursor_key(ctx)?, + }) + }), ); r.register( &PasteCell { @@ -258,7 +266,11 @@ pub fn default_registry() -> CmdRegistry { }) }, as_parsed, - Some(|_, ctx| Ok(PasteCell { key: cursor_key(ctx)? })), + Some(|_, ctx| { + Ok(PasteCell { + key: cursor_key(ctx)?, + }) + }), ); // clear-cell is registered above (unified: ctx.cell_key() or explicit coords) @@ -307,7 +319,11 @@ pub fn default_registry() -> CmdRegistry { }) }, as_parsed, - Some(|_, ctx| Ok(DrillIntoCell { key: cursor_key(ctx)? })), + Some(|_, ctx| { + Ok(DrillIntoCell { + key: cursor_key(ctx)?, + }) + }), ); r.register_nullary(|| Box::new(ViewNavigate { forward: false })); r.register_nullary(|| Box::new(ViewNavigate { forward: true })); @@ -742,9 +758,7 @@ mod tests { #[test] fn commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor() { let mut app = crate::ui::app::App::new(two_cat_model(), None); - app.view_state - .buffers - .insert("edit".into(), "decoy".into()); + app.view_state.buffers.insert("edit".into(), "decoy".into()); let ctx = app.cmd_context( crate::command::key::KeyCode::Null, crate::command::key::KeyModifiers::NONE, @@ -753,7 +767,11 @@ mod tests { ("Month".into(), "Feb".into()), ("Type".into(), "Clothing".into()), ]); - assert_ne!(ctx.cell_key().as_ref(), Some(&target), "the cursor is elsewhere"); + assert_ne!( + ctx.cell_key().as_ref(), + Some(&target), + "the cursor is elsewhere" + ); let args: Vec = ["editing", "5", "Type/Clothing", "Month/Feb"] .map(String::from) .into(); @@ -763,7 +781,10 @@ mod tests { .interactive("commit-cell-edit", &args, &ctx) .expect("commit-cell-edit resolves") ); - assert!(dbg.contains(&format!("key: {target:?}")), "wrong cell: {dbg}"); + assert!( + dbg.contains(&format!("key: {target:?}")), + "wrong cell: {dbg}" + ); assert!(dbg.contains("value: \"5\""), "wrong value: {dbg}"); } diff --git a/tests/headless_cli.rs b/tests/headless_cli.rs index 1789f9c6..f0751f16 100644 --- a/tests/headless_cli.rs +++ b/tests/headless_cli.rs @@ -410,7 +410,10 @@ fn commit_cell_edit_writes_the_named_cell_and_spares_the_cursors() { fn a_line_whose_gate_passes_runs_whole() { let scratch = Scratch::new("commit-atomic"); let doc = scratch.path("atomic.improv"); - let out = run_cmd(&["add-category Foo . commit-cell-edit editing 5 Foo/x"], &doc); + let out = run_cmd( + &["add-category Foo . commit-cell-edit editing 5 Foo/x"], + &doc, + ); assert!( !stderr_of(&out).contains("Parse error"), "a line the gate accepted was refused part-way: {}", @@ -418,7 +421,8 @@ fn a_line_whose_gate_passes_runs_whole() { ); let text = std::fs::read_to_string(&doc).expect("the document"); assert!( - text.lines().any(|l| l.contains("Foo=x") && l.ends_with("= 5")), + text.lines() + .any(|l| l.contains("Foo=x") && l.ends_with("= 5")), "the commit did not land: {text}" ); } From 912e25a0acf391e9096c4501fd4f805886ea4942 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 21:04:07 -0700 Subject: [PATCH 10/11] Rolled-back-load tests witness that the load ran (improvise-gho5) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/command.md | 36 ++++++++++++++++++++++--- crates/improvise-command/src/ui/host.rs | 32 ++++++++++++++++++---- 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index efcda9b0..fcbee537 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -2382,6 +2382,31 @@ keystroke to land in. Injected fault: dropping `self.view_state = snapshot.view_state` from `App::restore` fails this test and only this test. +**Addendum (improvise-gho5).** Improvise-gho5 found that this +test and `a_rolled_back_load_tells_nobody_and_puts_every_mirror_back` passed +against a host that **never ran the load**. The review's fault, reproduced +here, was placed at the top of `Host::commands`: refuse any multi-command frame +that contains a `LoadModel` with `Rejected(DocumentReplaced)`, before +`CheckedBatch` runs. Both tests stayed green. A frame that never got that far +leaves the generation, the view, the status line and every mirror untouched, +which is exactly what the tests asserted. + +Both tests now assert that the asker's whole reply is +`the_load_ran_then_the_write_broke()`, which is `Rejected(ChainBroken(North))`. +They also assert, before the frame, that `North` resolves. Together these make +the rejection a witness that the load ran. `ChainBroken` comes only from +`reduce::broken_chain`, which runs inside `mutating_frame` just before each +mutation. It names `North` only once the document no longer holds that +record, and only an applied `LoadModel` in this frame can have removed it. + +With the same fault re-injected, both tests fail. The left side is +`[Rejected(DocumentReplaced)]` and the right is +`[Rejected(ChainBroken())]`. The fault was reverted. + +The residual: a rogue up-front check that forged `ChainBroken` specifically +would still pass. That is the same forgery a hand-rolled client could commit +against any basis check, and a test cannot guard against it. + ### a_refused_mutation_rolls_back_its_predecessor_and_the_abort_flag The `Rejection::MutationRefused` arm — the one the first hand-back wrongly called @@ -4885,9 +4910,14 @@ Three things make it non-vacuous: `LoadModel` silently failed, `Applied::Rebased` would never run and every assertion here would pass over nothing. The control asserts the generation moves and that *both* clients are owed a `Snapshot`. -- **The rollback is asserted before anything is concluded from it** — the - `document_generation` equality is checked first, so "the frame was rejected" - cannot be confused with "the frame never got that far". +- **The load is witnessed to have RUN before anything is concluded from the + rollback** (improvise-gho5). The asker's whole reply must be + `Rejected(ChainBroken(North))`. `North` is asserted to resolve before the + frame, so only `broken_chain` can give that answer, and only after the + `LoadModel` has replaced the document. `CheckedBatch` never produces + `ChainBroken`. This bullet used to say the `document_generation` equality + did the job, and that was false: generation equality is exactly what holds + when the frame never got that far. - **A bystander is seated where a wrong relay could move it.** A single-client fixture cannot tell "the mirrors were restored" from "there was one mirror and it happened to be the asker's". diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index 50d7b392..166f16e0 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -1049,6 +1049,12 @@ mod tests { let was_view = host.app().view_state.active_view.clone(); let was_status = host.app().view_state.status_msg.clone(); let basis = frame_basis(&host, &["North"]); + assert!( + host.app() + .resolve_record(addressed("North").record) + .is_some(), + "North must resolve BEFORE the load, or a refusal naming it proves nothing" + ); // The load lands, then the write names a record the incoming document // has never had — so the frame fails at its second command. @@ -1069,11 +1075,10 @@ mod tests { ]), ); - assert!( - answer - .iter() - .any(|a| matches!(a.msg, ServerFrame::Rejected(_))), - "the fixture must actually refuse the frame: {answer:?}" + assert_eq!( + to(&answer, asker), + vec![the_load_ran_then_the_write_broke()], + "only a load that RAN can refuse the write this way: {answer:?}" ); assert_eq!( host.app().view_state.active_view, @@ -1097,6 +1102,12 @@ mod tests { ); } + /// `North` resolves in the outgoing document, so only `broken_chain` AFTER the + /// load applied can answer this. Evidence: `context/test-evidence/command.md`. + fn the_load_ran_then_the_write_broke() -> ServerFrame { + ServerFrame::Rejected(Rejection::ChainBroken(addressed("North").record)) + } + /// Everything one client was told, flattened to the projections it carries. fn projections(frames: &[Addressed], id: SessionId) -> Vec { to(frames, id) @@ -1147,6 +1158,12 @@ mod tests { let (bystander, _) = host.connect(10, 10); let was_generation = host.app().document_generation(); let basis = frame_basis(&host, &["North"]); + assert!( + host.app() + .resolve_record(addressed("North").record) + .is_some(), + "North must resolve BEFORE the load, or a refusal naming it proves nothing" + ); // The load lands and rebases every mirror; the write then names a // record the incoming document has never had, so the frame fails. @@ -1167,6 +1184,11 @@ mod tests { ]), ); + assert_eq!( + to(&answer, asker), + vec![the_load_ran_then_the_write_broke()], + "only a load that RAN can refuse the write this way: {answer:?}" + ); assert_eq!( host.app().document_generation(), was_generation, From 625c11319a93b01c7822300ca3e0c1fb714c06b2 Mon Sep 17 00:00:00 2001 From: Edward Langley Date: Tue, 22 Sep 2026 21:09:46 -0700 Subject: [PATCH 11/11] fmt-workspaces: exit 2 means no verdict, not no output (improvise-ximf) The unreached-package arm exits 2 and names the package's Cargo.toml, so the usage text's "NO file is named" was false for it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze --- context/test-evidence/tooling.md | 8 +++++--- scripts/fmt-workspaces.sh | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index bda78aae..c49691d2 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -417,9 +417,11 @@ getting wrong. The line the split actually needs is **"did the gate run"**: - exit 1 — rustfmt did not certify a root clean. A diff or a refusal to parse; either way **a file is named** and the tree is what is wrong. - exit 2 — the gate did not run. Usage, no cargo, no rustfmt, `cargo metadata` - failed, no `[workspace]` root. **No file is named.** Every such cause is - checked *above* the loop, which is what makes the narrowing true rather than - hopeful. + failed, no `[workspace]` root. **No source file is named.** Every such cause + is checked *above* the loop, which is what makes the narrowing true rather + than hopeful. (Since improvise-90ea, a package no root reaches is also exit 2, + and it names that package's `Cargo.toml`, which is the reason no verdict + exists. The line is "no verdict", not "no output"; improvise-ximf.) Both directions are now pinned, and two further rivals were injected: diff --git a/scripts/fmt-workspaces.sh b/scripts/fmt-workspaces.sh index 982d2211..98bc8370 100755 --- a/scripts/fmt-workspaces.sh +++ b/scripts/fmt-workspaces.sh @@ -19,7 +19,8 @@ exit 1 rustfmt did not certify a root clean: a file differs from rustfmt's and the tree is what is wrong. exit 2 the gate did not run: usage error, no cargo, no rustfmt, cargo metadata failed, no [workspace] root found, or a [package] that no - root has as a member. NO verdict was produced and NO file is named. + root has as a member. NO verdict was produced. No SOURCE file is + named; the unreached-package case names its Cargo.toml. The split is "did the gate run", not "is every exit-1 cause a diff". That is narrower than it sounds and it is deliberate -- see the note on rc=1 below.