diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index 7b3d29c3..1594097a 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -59,7 +59,8 @@ they mean, rather than testing for the variant **Layering.** `forbidden/2` in `kb.pl` is the authority: `improvise-core` no UI/IO, `improvise-io` no UI/commands, `improvise-command` no ratatui/crossterm. A ratatui type in core is the usual violation — the fix -is a semantic enum in core that the render layer maps (see `AxisColor`). +is a semantic value the render layer maps; for appearance that is a `Face` in +`improvise-protocol`, resolved by `draw.rs::face_style` (ADR 004). **`Option`/`Result`/`Box` at the boundaries.** Wrap at parse/lookup/IO, unwrap early, keep core logic on concrete values. Heap-container plumbing should be diff --git a/.agents/skills/pr-watch/SKILL.md b/.agents/skills/pr-watch/SKILL.md index c39a5254..b8e39f2f 100644 --- a/.agents/skills/pr-watch/SKILL.md +++ b/.agents/skills/pr-watch/SKILL.md @@ -5,13 +5,19 @@ description: Wait for a GitHub PR's checks to finish before merging or reporting # Waiting on a PR +"Watching a PR" means tracking **both** CI checks **and** GitHub comments/reviews. +A PR is not ready to merge just because checks are green — review feedback must be +read and answered first. + +## CI Checks + ``` -scripts/pr-watch [--expect ] [--timeout ] [--interval ] +scripts/pr-watch [--expect ] [--timeout ] [--interval ] [--reviews] ``` ``` -exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of - CLEAN, UNSTABLE or HAS_HOOKS +exit 0 every check COMPLETED, none failed, MERGEABLE with a state of + CLEAN, UNSTABLE or HAS_HOOKS, and reviews fetched (if --reviews) exit 1 a check failed (under any mergeability, BLOCKED included), or the PR is not mergeable (CONFLICTING, DIRTY, BEHIND, DRAFT, or a state it does not know) @@ -21,21 +27,70 @@ exit 3 timed out with checks still running, mergeability still UNKNOWN, exit 4 usage or environment error exit 5 the PR is MERGED or CLOSED, at the head the watch pinned; it ends on the first poll that sees it closed, which may be mid-wait +exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews") ``` +**`--reviews`** (default): after checks complete, print all reviews and comments +via `scripts/pr-reviews`. Use `--no-reviews` to skip this. The reviews are shown +but do not change the exit code — answering them is your responsibility. + **Exit 0 now means mergeable, not only green.** GitHub computes mergeability lazily and reports `UNKNOWN` for a while after the base moves. It also reports `BLOCKED` for the moment before a required check that `needs:` another job has registered. The watch waits out both, so you do not need to re-check `mergeable` by hand after it. **Exit 3 is not a pass.** It may be a required -check that never ran. Why each -state counts the way it does: `context/code/tooling.md`, "pr-watch and -mergeability" (improvise-edpl). +check that never ran. Why each state counts the way it does: +`context/code/tooling.md`, "pr-watch and mergeability" (improvise-edpl). **Pass `--expect ` whenever you know which commit you are asking about.** It is the difference between "this PR is green" and "the thing I pushed is green", and a PR here was once merged at a head that predated its own fix. +## Reviews and Comments + +Reviews are a **separate API surface** from the comment thread. Codex/ChatGPT +reviews arrive as *reviews*, not comments — checking only the comment thread +misses them entirely. `pr-watch --reviews` (the default) shows all three: + +| Surface | What it is | +|---|---| +| `[REVIEW]` | The review's own body (pulls/{n}/reviews) | +| `[INLINE]` | A comment on a line of the diff (pulls/{n}/comments) | +| `[COMMENT]` | A comment on the PR thread (issues/{n}/comments) | + +To fetch reviews directly: `./scripts/pr-reviews ` — **do not pipe it**. The +script's `$?` must be read directly; piping or chaining loses the exit code. +Redirect to a file if you need to process it. + +### Review Obligations + +Before treating a PR as done or merging: + +1. **Read all review feedback** — reviews against an earlier head may still apply +2. **Answer substantive feedback with attribution** — every reply must include + the attribution line (see below) +3. **Re-check after each push** that new reviews target the current head SHA +4. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows; + an absent review and an unrequested one look identical. The adversarial review + that gates a merge is dispatched by the orchestrator; external reviews are + read *in addition*, not instead. + +### GitHub Attribution (Hard Rule) + +**Every GitHub write must include an attribution line.** Posts go out under the +repository owner's account — an unattributed post reads as the human's words. + +Open the body with: `_Posted by ()_` + +Examples: +- `_Posted by Claude (Opus 4.5)_` +- `_Posted by Cursor Agent (claude-sonnet-5)_` + +A thread is not "answered" until the reply carries this attribution. + +See AGENTS.md "GitHub Attribution" for the full rule and "Shipping a wave" for +Copilot request mechanics and how to answer declined findings. + ## Do not write the loop yourself Four signals look like "finished" and are not: diff --git a/.agents/skills/wave/SKILL.md b/.agents/skills/wave/SKILL.md index 098907fe..3772166c 100644 --- a/.agents/skills/wave/SKILL.md +++ b/.agents/skills/wave/SKILL.md @@ -52,7 +52,10 @@ nobody corrects. The brief adds only what varies per wave: suite, which it is not. The agent still measures its own. 3. **Its ground, and the ground that is not.** Name any orchestrator-only file it is exceptionally allowed to edit. -4. **Its `BEADS_ACTOR` and its tickets.** +4. **Its `BEADS_ACTOR` and its tickets.** The actor also names the agent's + commits: it commits through `scripts/agent-git`, which authors each one as + `Claude ()` (`agent-rules.md`, "Discipline per ticket"). So + give each agent its own actor, and do not tell it to set `user.name`. 5. **The attribution line, commit trailers and PR footer**, spelled out. They name the agent (Claude's line opens `_Posted by Claude ()…_`) and this session's URL, which is why the shared `agent-rules.md` cannot diff --git a/.agents/skills/wave/agent-rules.md b/.agents/skills/wave/agent-rules.md index d3c437b1..9bcc7934 100644 --- a/.agents/skills/wave/agent-rules.md +++ b/.agents/skills/wave/agent-rules.md @@ -216,6 +216,33 @@ out on purpose: - **Claim first:** `export BEADS_ACTOR=` in every shell, then `bd update --claim`. Exit 13 means another actor holds it. Pick another; do not retry. +- **Author every commit as `Claude ()`.** The user: "agents + should override git author name, I think to include an agent identifier + (matching BEADS_ACTOR)" (2026-09-25; improvise-1xfb.3). Make every commit + through the wrapper, merge commits included: + `BEADS_ACTOR= scripts/agent-git commit …`, and the same for + `merge`. The wrapper sets the author name for that one command, and it refuses (exit 2) when `BEADS_ACTOR` is unset or empty. It + reads `BEADS_ACTOR` from its own environment, so the prefix works in a shell + that keeps nothing between commands. + - **The author email, the committer and the `Co-Authored-By` trailer stay as + they are.** The email is the owner's configured one, so GitHub still links + the commit. + - **Never set `user.name` with `git config`.** Worktrees share + `.git/config`, so it renames every agent at once. Do not use + `git -c user.name=…` either: `user.name` names the committer as well as + the author. Why the wrapper uses `GIT_AUTHOR_NAME`: + `context/test-evidence/tooling.md`. + - A plain `git commit --amend` keeps the author it amends, so amend with + plain git or with the wrapper. + - **A cherry-pick keeps its source's author**, through the wrapper or not: + git ignores `GIT_AUTHOR_NAME` there. That is right, because rule 8 allows + only `cherry-pick -x` of commits that keep whoever wrote them. + - Before you push, check `git log --format='%an <%ae> | %cn' ..HEAD`. Every commit **you made** must name your actor. A + cherry-picked commit, or one from a PR you stack on, keeps its own author. + - **The format is a proposal.** `Claude ()` is the default + proposed on improvise-1xfb.3's PR. The user may change it there. The format + lives in `scripts/agent-git` and nowhere else. - **`bd` needs `.envrc` allowed in your worktree.** Without it you get a **version mismatch**, not a clean failure. Run `direnv allow` in the worktree, or rely on the `[whitelist] prefix` in `~/.config/direnv/direnv.toml`. diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 8b1317db..a9267260 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,11 +1,23 @@ -{"_type":"issue","id":"improvise-7mro","title":"Browser keys resolved while a scroll is unanswered read the cache through the new offsets: pipelined $ i 7 Enter writes Jan/Revenue where the TUI writes Feb/Revenue","description":"Found while delta-reviewing PR #282 at 6942e53e. This is pre-existing: the reviewer's scratch lockstep gives identical results at gh/main 202e7eac, at #277 aab62a36 and at #282 6942e53e. #282 fixed this exact aliasing in `declined_at` only (improvise-y5eb), with the new `projected_at`, which reads through `AxisFrame::first`.\n\n`resolve::cell_at_cursor` (and `cell_at`/`seed_at`) index `GridCache` with the view's cursor offsets. The client folds its own scroll (SetRowOffset/SetColOffset) at once, but the cache keeps the old window until the host answers. Any key resolved in that gap reads a different cell from the one under the cursor. That includes `edit_or_drill` (`i`), `commit` (its `id`, and `record_at_cursor` for the write's target) and the seed.\n\nWorst case, a silent write to the wrong record. Fixture: `_Measure` rows [Revenue, Profit (explicit item + `Profit = Revenue - Cost`), Cost], Month [Jan, Feb] on columns, every cell stored. Client window (2 rows, 1 col), cursor at (0,0). Keys `$`, `i`, `7`, `Enter` are all folded and sent before any reply is delivered:\n- TUI: Feb/Revenue = 107.\n- Host, fed the client's frames: **Jan/Revenue = 107**, and Feb is untouched. The client read Jan's cell for the seed and for the record the write claims.\n\nOther shapes on the same fixture, with replies held:\n- `G`, `i` from (0,0) in a 2×6 window: the client declines \"Profit is computed by a formula\" on Cost/Jan. The TUI opens.\n- `g g i` from row 2 with row offset 1: the same wrong decline.\n- `G i 7 Enter`: the TUI stores Jan/Cost = 47, and the client writes nothing.\n\nWhen each reply is delivered before the next key, every one of these agrees. So the trigger is pipelining faster than the host answers: a websocket with latency, or a fast typist over a worker.\n\nFix direction: read every cache cell through the frame's own origin (`projected_at`), and treat \"not projected yet\" as unknown rather than as a different cell. The write target especially should never be read through the cursor offsets.\n\nAC: a lockstep test that holds replies across a scroll and then `i`/type/`Enter`, comparing the host's document with the TUI's. It must fail at 6942e53e on the `$` case.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-7mro","owner":"el-github@elangley.org","created_at":"2026-09-24T01:58:28Z","created_by":"agent-review-277","updated_at":"2026-09-24T02:05:55Z","started_at":"2026-09-24T02:05:55Z","lease_expires_at":"2026-09-24T02:10:55Z","heartbeat_at":"2026-09-24T02:05:55Z","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jq0m","title":"A collapsed group's fold scans every item of its category: grid of collapsed small groups is quadratic, ~25x pre-#239 at 1000 items (yb4b fix regression)","description":"Found in the adversarial review of PR #239 at head f668ddc9 (improvise-yb4b's fix).\n\n`fold_stored` now reads through `DataStore::spanned_values`. That call narrows by `base`, the key with each folded category REMOVED, and then filters each candidate by membership. For a collapsed group, `base` drops the grouped category entirely. So each fold cell scans every record under (other coords), across EVERY item of the category, not just the group's members. The cost per fold cell is O(items in the category), not O(group size). A grid of collapsed small groups is therefore quadratic in the category's size. The old per-member lookup was O(group size) per cell.\n\nMeasured on an Apple M4 Max (16 cores, 64 GB) at load average 50-115, keypress = grid_of(whole grid) + compute_col_widths, min of 10 iterations, 2 alternating rounds. The probe is a dense grid: Day grouped and collapsed on Row, Product on Row, _Measure on Column.\n\n| case | 5932ad74 (pre-#239) | 9948aaf5 | f668ddc9 |\n|---|---|---|---|\n| 364 days in 7-day groups, 20 products, 6 measures | 0.40-0.52 s | 0.92-1.21 s | 1.73-1.75 s |\n| 364 days in pairs, 20 products, 6 measures | 0.54-0.64 s | 0.95-1.09 s | 4.9-6.3 s |\n| 1000 days in pairs, 10 products, 4 measures | 0.44-0.51 s | 0.62-0.65 s | 11.0-15.7 s |\n\nAt 1000 items the fix is ~25x the pre-#239 keypress and ~20x #239's own earlier head. The `grid_projection` bench cannot see this: it has no groups, so its only folds are the column totals, which really do span the whole category.\n\nLikely fix: narrow per span, not per base. For a span whose covered items are few, go through `index[(cat, item)]` for each covered item, intersected with the base. Alternatively, choose between the scan and a member enumeration by comparing the candidate count with the member count. Keep the positions sorted, per gotcha 18. Add a grouped case to benches/grid_projection.rs so the next change is measured against it.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-24T01:05:19Z","created_by":"agent-review-239b","updated_at":"2026-09-24T01:12:29Z","started_at":"2026-09-24T01:12:29Z","lease_expires_at":"2026-09-24T01:17:29Z","heartbeat_at":"2026-09-24T01:12:29Z","labels":["adversarial-review","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3tt4","title":"Tab at the rightmost column reopens the browser editor on the stale pre-write cache value, so typing overwrites the value just committed (#227 regression)","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade. This is a regression introduced by that PR.\n\n`resolve::advanced` seeds the reopened editor from the client's cache (`seed_at`). A comment there says \"The new cell is a different cell, so a stale cache is correct here\". That is false for `Advance::Right` at the rightmost column, which clamps `(col+1).min(col_max)` onto the cell that was just written. The TUI's `EnterEditAtCursor` re-seeds from the model after the write. Before #227 the welded trailing `clear-buffer` blanked both seeds, so the two ends agreed on `\"\"`. #227 removes the weld, so the stale seed becomes visible.\n\nMeasured with the lockstep harness (`precise_host`, cursor (0,1), North/Revenue = 2, window (2,2)):\n- `i`, `9`, `Tab`: the browser buffer is `\"2\"` and the TUI buffer is `\"29\"`. At #205's head 7aff3e77, both are `\"\"`.\n- `i`, `9`, `Tab`, `5`, `Enter`: the browser writes **25**, overwriting the 29 the user just committed. The TUI writes 295. At 7aff3e77, both write 5.\n\nIn a real browser the projection that updates the grid does not repair this. `settle_editor` seeds only an ABSENT buffer, and `opened_editor` stays on the stale `\"2\"`.\n\nThe fix belongs in #227. Where the landed cell is the cursor's own cell, seed with the committed value's edit text, the same text the host's `display_value_of` would read after the write. Or derive the seed so it cannot read the pre-write cache for the written cell. Acceptance: a lockstep test that Tabs at the rightmost column and compares buffers, and the typed-extension variant comparing both ends' cell values. Both fail at 8ee5fade.\n","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:02Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:35:10Z","started_at":"2026-09-23T15:35:10Z","lease_expires_at":"2026-09-23T15:40:10Z","heartbeat_at":"2026-09-23T15:35:10Z","labels":["adversarial-review","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8tk5","title":"ADR 005 clause 3: with no persisted high-water mark, ids are reused after a reload and collide on merge, which defeats the out-of-session identity persistence was for","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nClause 3 has the file record no high-water mark: \"after a reload the counters restart at max id seen + 1\". It argues that reuse is harmless because `document_generation` rejects stale commands. That argument covers the wire only. The ADR's own case for persisting ids (\"Alternatives rejected\") is identity *outside* a session: \"a rename visible as a rename in git diff, and a future reference that survives a restart\". Clause 3 defeats both.\n\n- Reuse after reload. Categories A(3) and B(4). Delete B, save, reload, add C. C gets 4, because the max seen is 3. `git diff` from the first commit to the last shows `B(4)` becoming `C(4)`, which reads as a rename, and an external reference to 4 now silently names C. That is the \"silently names a different thing\" failure clause 3 cites from ADR 002 as the thing ids must never do.\n- Merges. Two branches of one `.improv` each add a category, and both get the next integer. The merged file has a duplicate. Clause 5's repair keeps the first and reassigns the second, so one entity's persisted id changes on merge. Nothing in the file breaks, since nothing refers to an id. But the \"stable across a save and a reload\" promise does not extend to copies or branches, and the ADR does not say so.\n- The generation argument is also per-process: `document_generation` starts again at 0 in a new host process.\n\nMissing open question: whether to persist a high-water mark (for example a `next-id:` line, or per kind), and whether ids should be collision-resistant tokens rather than counters. That bears directly on Q1: integer versus word is not only about readability. Q9's argument (\"ids reset on every load gives back most of what persistence was asked for\") is weaker than stated, because under clause 3 ids can already be reused after a load.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:10Z","dependencies":[{"issue_id":"improvise-8tk5","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-80rm","title":"ADR 005 Q3 rests on a false premise: the CSV importer writes 'Name(token)' headers today, and header_decl would silently orphan their data","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nADR 005 open question 3 recommends accepting that \"two rare existing header spellings change meaning on load\" and calls `Q3(draft)` \"a spelling nobody is known to use\". That premise is false: improvise's own CSV importer writes that shape today, from ordinary column headers.\n\nReproduced at 4b880846 (gh/main): `improvise import t.csv --no-wizard` on a CSV with headers `Size(cm),Region(code),Amount` saves\n\n ## Category: Region(code)\n ## Category: Size(cm)\n\nbecause `quote_header` has no case for a parenthesised suffix. Under the ADR's `header_decl` these load as category `Region` with id `code`, and `Size` with id `cm`. Every reference keeps the old spelling (clause 5), so `|Region(code)|: row` and every `## Data` key `|Region(code)|=N, ...` name a category the document no longer declares.\n\nLoading the file the way the new grammar would read it (the headers rewritten to `Region`/`Size`, references untouched) through today's `parse_md` succeeds with no error or warning. The axis lines for the old names are dropped, and every cell stays keyed under a category that does not exist, so none of them is drawn. This is not a rename. It silently orphans all the data and view configuration of a CSV-imported document. `Initial View: Q3(draft)` dangles the same way for a view, and falls back to the default without saying so.\n\nWhat the ADR must do: restate Q3's cost as silent data orphaning, and name the importer as a producer of the shape. Also consider an alternative: rewrite references that still spell the pre-split name on load, or warn and refuse. A sigil is not the only alternative.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:08Z","dependencies":[{"issue_id":"improvise-80rm","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.12","title":"ADR 007 step 2b: displays in the grid frame, page bar, records labels, item rows and CSV export","description":"The second slice of improvise-cq1q.3 (ADR 007 step 2). The first slice put `display` on Category, Item and View, and made the category-panel headers, the tile bar and the view list paint it in both front ends. This slice covers the rest of the grid and the panels:\n- item rows in the category panel (CategoryRow.items needs an item display beside each key);\n- the grid frame's label tuples and group headers;\n- the page bar (PageAxis cat/selected/items);\n- records-mode column headers and coordinate cells;\n- CSV export headers (persistence/export.rs render_csv).\n\nConstraint: the frame tuples are also cell coordinates (`cell_key`, `innermost_group`, `run_start`), so the display must be added at projection time (for example, a display tuple beside the key tuple in AxisFrame), never swapped in place. The page bar's items are cycled by the browser (`SetPageSelection`), so keys stay beside displays there too. See context/code/core.md §21.\n\n## Acceptance Criteria\n- Tests first, each seen to fail. With a category, an item and a group each displayed differently from its key:\n - the TUI grid, the browser render model and CSV export draw the displays;\n - `cell_key` and every command a client composes still name keys.\n- Rival: a frame whose tuple carries the display instead of the key. Cell addressing must fail.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:38Z","dependencies":[{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:39Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.12","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:48Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"2c593fb5-4faa-5695-9e21-3e00aa3ee75b","issue_id":"improvise-cq1q.12","author":"Edward Langley","text":"From the adversarial review of PR #328 at 3116d524 (Claude agent, in the owner's session):\n\n1. **One more surface that still paints category keys.** The view panel's axis summary, `viewmodel::view_axis_summary` (for example `R:Region,Product`), is drawn by `src/ui/view_panel.rs` and by `wasm-client/render.rs` (`RenderViewRow.axes`). `ViewSummary.axes` holds `(category key, Axis)` only, so it needs a display beside each key, just as the frame does. It is not listed here or in cq1q.13.\n2. **A test gap to close in this slice.** A fault in which `view_summaries` gives every view the ACTIVE view's display passes the whole workspace. Add a second view whose display differs from both the active view's display and its own key.\n","created_at":"2026-09-25T19:38:50Z"}],"dependency_count":1,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-cq1q.5","title":"ADR 007 step 4: .improv ids — new version literal, id grammar, loader refusals, writer, round-trip properties","description":"ADR 007 clauses 5, 9 and 10: persistence, and migration step 4.\n- A second version literal. The grammar's `file` rule gets one arm per literal, and the version line alone picks the regime.\n- The new arm reads declarations with an id (header_decl, item_decl, grouped_item) and references as id_ref. id_token is spelled with ranges (improvise-rxz9). axis_line is tried before format_line (improvise-le4p).\n- The old arm is read exactly as today: whole-line headers, references resolved against displays, ids assigned by slugify, and undeclared items declared at load (answer 24).\n- The loader refuses a new-version file with a duplicate id, a missing id, an id outside the alphabet or a reserved `_` id. The error names the line, and for a duplicate it names both lines.\n- The writer always writes the new literal, and pipe-quotes any display that would otherwise split (`## Category: |Region(code)|(regioncode)`, improvise-80rm).\n- The round-trip properties from clause 5, plus a formula-line generator strategy (improvise-rxz9), so the formula properties are not vacuous.\n\nThe formula-line syntax waits for improvise-m56i.\n\n## Acceptance Criteria\n- Tests first, each failing before the change:\n - an old file with `## Category: Region(code)` loads with display `Region(code)` and id `regioncode`, and its data stays drawn;\n - a new-version file keeps every id verbatim, including `east-3`;\n - duplicate, missing and capitalised ids are each refused with the line named;\n - a category displayed `Format` on a row axis round-trips;\n - format(parse(format(parse(x)))) == format(parse(x)) holds.\n- Evidence that the formula strategy emits `=` lines the walker keeps: count them.\n","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:49:41Z","dependencies":[{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.1","type":"blocks","created_at":"2026-09-25T04:50:54Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.5","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:53Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.4","title":"ADR 007 step 3: re-key storage by id (Model, Workbook views, CellKey, DataStore, View config, RecordHash)","description":"ADR 007 clauses 2, 3 and 8, and migration step 3: the wide re-key. Every creation site takes its key from the workbook's IdAllocator (step 1): add_category, add_item, add_item_in_group, create_view and add_formula's target item. The display is the typed name.\n- Model::categories and Workbook::views are keyed by id.\n- CellKey is a sorted list of (CategoryId, ItemId), sorted by category id, so gotcha 5's canonical form holds under the new sort key. DataStore interns ids.\n- View config is keyed by id: axis maps, page selections, and hidden and collapsed sets (collapsed stays (CategoryId, group name), per improvise-p003).\n- RecordHash is computed over id text (clause 8). Update ADR 002's prose in the same PR, since the ADR assigns that update to the implementation PR.\n- Display-\u003eid resolution moves to the input edges: minibuffer commits, the command-line/script parser (coordinate with improvise-31oe.2 and improvise-31oe.3), and formula reference resolution.\n\nThis step is blocked by the user question in improvise-m56i on how typed formula names resolve (item 6) and on a non-_Measure formula target's identity (item 2).\n\n## Acceptance Criteria\n- Tests first: after add-category \"Time Period\", the key is `time_period` and the display is \"Time Period\".\n- Adding `East` to Region and to Direction gives `east` and `east-1` (answer 22).\n- A display rename (a test hook until fz77) moves no CellKey, no RecordHash and no layout_version. This is the property the design exists for, so assert that the labels changed and the surrogates did not. The rival, re-keying on rename, must fail it.\n- The wasm gate passes for every bundle crate.\n","notes":"Scope added for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now owns the OLD-format .improv edge, because it is the only format until cq1q.5: the old arm's loader assigns ids by slugify and resolves references against displays (answer 24 included), and the writer writes DISPLAYS in the old format. cq1q.5 keeps the new version literal, the id grammar, the refusals and the writer switch. Wire operands are already keys, via cq1q.7, which now lands first. Added acceptance criteria: (1) a document with categories displayed Region and Café, and item East, survives save and load at this step with every display intact. The rival, a writer that writes keys, gives region/caf/east and fails. (2) The browser round-trip suite (tests/browser_round_trip.rs, tests/lockstep.rs) stays green with no display-\u003eid resolver in ui/reduce.rs. The main branch is shippable after this step.\nUpdated for the user's decisions of 2026-09-25 (ADR 007 answers 30 and 31; PR #326). A formula target outside _Measure keeps a real item with its own ItemId, allocated like any item, and the formula gets a separate FormulaId (answer 30). Typed names are resolved at the CLIENT, displays first and then ids, and an ambiguous display is refused with the candidates' ids listed (answer 31). The host-side edges in this step, the minibuffer commits and the script parser, follow the same order. Formula references are resolved by the client/editor edge, never by the host from display text (answer 32). The dependency on improvise-m56i is satisfied: it is answered.","status":"open","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:37Z","dependencies":[{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:27Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.12","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.13","type":"blocks","created_at":"2026-09-25T19:10:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.2","type":"blocks","created_at":"2026-09-25T06:41:57Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T04:50:50Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T06:41:47Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.4","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:52Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":6,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.3","title":"ADR 007 step 2: a display field on Category, Item and View, read by every renderer (keys unchanged)","description":"ADR 007 clause 2, first half. Category, Item and View each gain `display: String`. Every place that shows a name to a person reads `display`: the grid frame labels, the category, formula and view panels, the tile bar, the page bar, CSV export headers, the minibuffer prompts and the status messages. The key is still the name, and `display == name` everywhere at the end of this step, so behaviour does not change. This splits \"what people see\" from \"what keys the map\" before any key moves, so the re-key step (the next bead) changes only what goes into the key. It follows the user's answer 1: \"reserve `name` ... for the id\".\n\nCoordinate with improvise-felo.6, which is working in category.rs and view/layout.rs. Land after it, or merge.\n\n## Acceptance Criteria\n- A test-only document whose display differs from its key (constructed through a test hook, since no production path can build one yet) shows the display in the grid frame, the category panel, the view panel, the tile bar and the CSV export header. The key never appears.\n- Rival: revert one renderer to read the key, and the matching test fails.\n- The whole suite is otherwise unchanged.\n","notes":"Split for review size (agent-adr007, 2026-09-25). This bead is the FIRST slice: display on Category/Item/View; CategoryRow/TileEntry/ViewSummary carry display beside the key; the category-panel headers, the tile bar and the view list paint it in the TUI and the browser. The rest is improvise-cq1q.12 (grid frame, page bar, records labels, item rows, CSV export) and improvise-cq1q.13 (status messages, view title). cq1q.4 now depends on both.","status":"in_progress","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:51Z","started_at":"2026-09-25T18:59:52Z","lease_expires_at":"2026-09-25T19:04:52Z","heartbeat_at":"2026-09-25T18:59:52Z","dependencies":[{"issue_id":"improvise-cq1q.3","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:14Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.2","title":"ADR 007 step 1: slugify and the gensym id allocator (pure, improvise-core)","description":"ADR 007 clause 4: slugify, and the gensym-style id allocator. A pure module in improvise-core with no callers yet. It is migration step 2's first piece and depends on nothing.\n\n- `slugify(display)` does:\n 1. Unicode full case folding (answer 26). The `unicase` crate provides it; it is no_std, has no dependencies and is wasm-clean.\n 2. Each run of spaces becomes one `_`.\n 3. Every character outside a-z, 0-9, `-` and `_` is stripped.\n 4. Leading `_` is removed.\n 5. An empty result falls through to the allocator.\n- `IdAllocator`: one increment-only counter per document, starting at 1. A generated id stays bare unless it collides. A collision takes `-\u003cn\u003e` from the counter, advancing again until the candidate is free (answers 27, 28 and 29). An empty slug becomes `\u003ckind\u003e-\u003cn\u003e`. The allocator keeps every id it has ever seen, so a removed id is never reissued within a session.\n- `claim(id)` is how a hand-written id enters the allocator. It refuses a duplicate (answer 29), an id outside the alphabet, and a leading `_` other than the virtual ids `_index`, `_dim` and `_measure` (answer 23). A claimed id is never renamed, because hand-written ids are canonical.\n\n## Acceptance Criteria\n- Tests first, each shown failing: the ADR's examples (`East`-\u003e`east`, `Time Period`-\u003e`time_period`, `Region(code)`-\u003e`regioncode`, `Café`-\u003e`caf`, `Straße`-\u003e`strasse`, `_Draft`-\u003e`draft`, `Ü`-\u003e`category-\u003cn\u003e`); a collision across kinds; a claimed `east-1` that forces `east-2`; retirement; determinism across two independently built allocators.\n- Each test's rival injected and seen to fail: `to_lowercase` instead of folding (`Straße`), a lowest-free-number suffix, and replacing each single space instead of the run.\n- The wasm check passes for improvise-core, and harness/opfs-contract's lock is updated for the new dependency.\n","status":"closed","priority":1,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:51:51Z","started_at":"2026-09-25T04:51:42Z","closed_at":"2026-09-25T17:51:51Z","close_reason":"PR #318 merged (6da6a040, head 7ddc50ac); reviewed PASS (issuecomment-5836573405); cq1q.11 closed by the re-sequenced bead graph","dependencies":[{"issue_id":"improvise-cq1q.2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:02Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"a07b9c7f-8972-53a8-8b97-aed300617104","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"PR #318 opened, branch adr007/slugify, head a2ddb06e. It adds model::id (slugify, IdAllocator, claim) and the unicase dependency. All 11 tests were written first, and each of the six rivals was caught (context/test-evidence/core.md). Every gate is green locally after merging gh/main c91cc055.","created_at":"2026-09-25T05:15:38Z"},{"id":"13895058-8d4e-538d-9939-3ed93a325d69","issue_id":"improvise-cq1q.2","author":"agent-adr007","text":"Pushed 689480bf: Cargo.nix regenerated with crate2nix for unicase. The first CI run's crate2nix job would have failed without it. nix build .#improvise passes locally.","created_at":"2026-09-25T05:37:45Z"}],"dependency_count":0,"dependent_count":1,"comment_count":2} +{"_type":"issue","id":"improvise-31oe.1.1","title":"Complete command names on the : command line","description":"First child of the auto-complete epic; the user's top priority. On the ':' command line, completing the command name offers every registered command (names read off the registry's prototypes, gotcha 15 -- no string list), filtered by the typed prefix. Decide and test: the key (Tab, with what on an exact match), single-match insertion, multi-match display (cycle vs list), and that the TUI and the browser use the same engine (improvise-y11g brings the ':' line to the browser). Tests first: every registered command is completable; a prefix narrows; an unknown prefix offers nothing; the candidate set equals the registry's name set (so a new command cannot be missing).","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:48Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1.1","depends_on_id":"improvise-31oe.1","type":"parent-child","created_at":"2026-09-25T03:41:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.2","title":"Import drops whole records that lack a category value; keep them with the coordinate absent","description":"USER 2026-09-25, verbatim: 'this limit in import should be fixed, it dates to when this wasn't sparse.' Screenshot: 'Skipped 6 of 342 records missing category values: Settlement Date (6)'. build_model_with_warnings (crates/improvise-io/src/import/wizard.rs ~262-284) skips any record with no value for an accepted Category field; improvise-5j9 only made the loss visible. Records are sparse, so such a record should be KEPT with that coordinate absent from its CellKey. Same mechanism as the sparse folder merge (improvise-lpzu), where a whole file lacks a category column -- so it belongs with that work. Decide and test how an absent coordinate behaves downstream: a view with that category on Row/Column (is the record invisible there, shown under a blank/(none) row, or counted only in totals?), on Page (which selection shows it), on None (aggregates include it), records mode (blank cell in that column), .improv round-trip (a Data line simply omits the category), drill, and ADR 007/identity. The display question for Row/Column is the user's to confirm; state the recommendation in the PR.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:21Z","started_at":"2026-09-25T03:37:52Z","closed_at":"2026-09-25T05:02:21Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T03:37:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.1","title":"Raise MAX_CATEGORIES from 12 to 24","description":"USER DECISION 2026-09-25, verbatim: '12 categories is feeling small, we should increase to 24.' Prompted by an Etrade transactions import that hit exactly 12 categories, which left no room for the duplicate-disambiguating category of improvise-cgqt (PR #304). Supersedes context/plan.md:260 ('Do not raise or restructure the MAX_CATEGORIES = 12 limit'). The limit lives in crates/improvise-core/src/model/types.rs and is MIRRORED by a private copy in crates/improvise-io/src/import/wizard.rs — make the wizard read the model's (one home), rather than changing two numbers. Update every doc that states 12 (README, design-principles, context/code/*, plan.md) and every test that fills to the cap by literal count; tests should derive the cap, not spell it. Before changing it, find and state why 12 was chosen (any cost that scales with category count: CellKey size, the secondary index, layout cross products, formula fold) and measure one representative operation at 24 categories.","status":"closed","priority":1,"issue_type":"feature","assignee":"wave-felo1","owner":"el-github@elangley.org","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:10:53Z","started_at":"2026-09-25T02:07:37Z","closed_at":"2026-09-25T03:10:53Z","close_reason":"Landed in PR #307; reviewed PASS at 74e460be","labels":["core","import"],"dependencies":[{"issue_id":"improvise-felo.1","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:06:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.1","title":"TUI horizontal scrolling loses the cursor: grid cursor hides behind the category panel, tile cursor goes off screen","description":"Reported by the user 2026-09-25, possibly a regression. (1) Navigating the data grid, the selected cell can end up BEHIND the category panel -- the viewport's column window is computed as if the grid had the full frame width, not the width left after the side panel. (2) Navigating the tile bar, the tile cursor can move completely off screen -- the tile bar does not scroll to keep its cursor visible. Hypotheses to check, not facts: viewport_effects / compute_col_widths / visible_cols (improvise-command ui/viewport.rs, cmd/navigation.rs) take an extent that ignores panel_open; the tile bar widget in src/ui has no horizontal offset. Bisect for a regression (e.g. the grid-from-cache work improvise-anr9, extent/panel changes) and say which commit if so. Tests first: a unit test over the viewport computation with the category panel open that fails today, and one for the tile cursor at the far right of a bar wider than the screen.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-gtn7-1","owner":"el-github@elangley.org","created_at":"2026-09-25T01:20:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:41:37Z","started_at":"2026-09-25T01:21:15Z","closed_at":"2026-09-25T01:41:37Z","close_reason":"Fixed in PR #303 (merged); reviewed PASS at 241cc5f3. Follow-ups: gtn7.2, j7ij, j4no.","labels":["regression?","tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.1","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:20:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pmt0","title":"Epic: Wire vocabulary, projection and multi-client sessions","description":"The shared session machinery in improvise-protocol and improvise-command/ui (reduce.rs, project.rs, host.rs): commands a client cannot yet name on the wire (1ohv, 2mqz, the browser : line y11g), where effects and their wire mirrors disagree, the per-view scoping of the active view and aggregate addressing (azhb, h074, obtu, 8id2, znuk), projection cost and the frame/shape fingerprints behind cursor invalidation (swre, mcq4, b8ng, 9v0j), and protocol-level test gaps (nmm5, 9xsp). Out of scope: the ADR 001 basis and force work (its own epic), browser key resolution and ClientState (improvise-6jk), and security of wire input (the security epic).","status":"open","priority":1,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:10Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q","title":"Epic: Stable identities — implementing ADR 007","description":"Implementing context/adr/007 (stable ids for categories, items, formulas and views, decided in improvise-sboz), and the adversarial-review findings about gaps in the ADR itself. Some of those tickets were filed when the ADR was numbered 005. This covers the .improv id grammar, the persisted high-water mark, re-keying ViewState and projections from names to ids, formula identity, and the wire clauses for ids. Out of scope: ADR 005's exact-decimal arithmetic (the core-model/formula epic), and concurrent-edit semantics that do not depend on ids (the ADR 001 epic).","status":"open","priority":1,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:09Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3uow","title":"pr-reviews-test hard-codes /bin/sleep and /bin/date, absent on the NixOS CI runner: the kb job goes red on it once #296 lands","description":"## Summary\nFound reviewing PR #296 at head 4989bd55, which fixes main's red \"Knowledge base consistency\" job.\n\nCI now runs on `[self-hosted, nixos, microvm]`. On NixOS, `/bin` holds only `sh`, the same premise #287 and #296 rest on. `scripts/pr-reviews-test.sh` hard-codes absolute paths there:\n- **`/bin/sleep \"$BOUND\" \u0026`**, line 17, the watchdog in `bounded_reviews`. Where it does not exist, the background job exits 127 at once, `wait -n` sees the watchdog finish first, and `bounded_reviews` returns 124: \"killed after 20s\".\n- **`exec /bin/date \"$@\"`**, line 221, inside the fake-clock stub. It is reached only for a `date` call other than `+%s`.\n\n## Evidence\n- **main's red run (35961230942, runner `ghr-1`) never reached pr-reviews-test.** The kb job stops at \"The checker runner refuses what it claims to\" (checkers-test 12/2). pr-reviews runs in the next step, \"The remaining checkers…\", so it has **never run on the NixOS runner**.\n- **Simulated on macOS.** I ran a copy of the suite through `checkers.sh` with `/bin/sleep` rewritten to a nonexistent path, standing in for NixOS. Every `--wait` case fails with exit 124, \"killed\". The unmodified suite passes 25/25 on the same machine.\n\nSo once #296 makes checkers-test pass on NixOS, the kb job is expected to go red again, on pr-reviews-test.\n\nNot verified on a real NixOS runner: PR #296's CI was still queued when this was filed.\n\n## Fix\nResolve `sleep` and `date` once, before any case puts a stub on PATH (for example `SLEEP=$(command -v sleep)` at the top), and use those paths. Do not use `/bin/...`. Add the remaining `/bin/\u003ctool\u003e` uses to whatever grep found `/usr/bin:/bin`.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-main-red","owner":"el-github@elangley.org","created_at":"2026-09-24T06:18:31Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:57Z","started_at":"2026-09-24T06:23:31Z","closed_at":"2026-09-24T21:44:57Z","close_reason":"Landed in main via #297 (reviewed; merged 2026-09-24).","labels":["adversarial-review","ci","pr-296"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7mro","title":"Browser keys resolved while a scroll is unanswered read the cache through the new offsets: pipelined $ i 7 Enter writes Jan/Revenue where the TUI writes Feb/Revenue","description":"Found while delta-reviewing PR #282 at 6942e53e. This is pre-existing: the reviewer's scratch lockstep gives identical results at gh/main 202e7eac, at #277 aab62a36 and at #282 6942e53e. #282 fixed this exact aliasing in `declined_at` only (improvise-y5eb), with the new `projected_at`, which reads through `AxisFrame::first`.\n\n`resolve::cell_at_cursor` (and `cell_at`/`seed_at`) index `GridCache` with the view's cursor offsets. The client folds its own scroll (SetRowOffset/SetColOffset) at once, but the cache keeps the old window until the host answers. Any key resolved in that gap reads a different cell from the one under the cursor. That includes `edit_or_drill` (`i`), `commit` (its `id`, and `record_at_cursor` for the write's target) and the seed.\n\nWorst case, a silent write to the wrong record. Fixture: `_Measure` rows [Revenue, Profit (explicit item + `Profit = Revenue - Cost`), Cost], Month [Jan, Feb] on columns, every cell stored. Client window (2 rows, 1 col), cursor at (0,0). Keys `$`, `i`, `7`, `Enter` are all folded and sent before any reply is delivered:\n- TUI: Feb/Revenue = 107.\n- Host, fed the client's frames: **Jan/Revenue = 107**, and Feb is untouched. The client read Jan's cell for the seed and for the record the write claims.\n\nOther shapes on the same fixture, with replies held:\n- `G`, `i` from (0,0) in a 2×6 window: the client declines \"Profit is computed by a formula\" on Cost/Jan. The TUI opens.\n- `g g i` from row 2 with row offset 1: the same wrong decline.\n- `G i 7 Enter`: the TUI stores Jan/Cost = 47, and the client writes nothing.\n\nWhen each reply is delivered before the next key, every one of these agrees. So the trigger is pipelining faster than the host answers: a websocket with latency, or a fast typist over a worker.\n\nFix direction: read every cache cell through the frame's own origin (`projected_at`), and treat \"not projected yet\" as unknown rather than as a different cell. The write target especially should never be read through the cursor offsets.\n\nAC: a lockstep test that holds replies across a scroll and then `i`/type/`Enter`, comparing the host's document with the TUI's. It must fail at 6942e53e on the `$` case.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-7mro","owner":"el-github@elangley.org","created_at":"2026-09-24T01:58:28Z","created_by":"agent-review-277","updated_at":"2026-09-24T21:45:03Z","started_at":"2026-09-24T02:05:55Z","closed_at":"2026-09-24T21:45:03Z","close_reason":"Landed in main via #286/#295 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jq0m","title":"A collapsed group's fold scans every item of its category: grid of collapsed small groups is quadratic, ~25x pre-#239 at 1000 items (yb4b fix regression)","description":"Found in the adversarial review of PR #239 at head f668ddc9 (improvise-yb4b's fix).\n\n`fold_stored` now reads through `DataStore::spanned_values`. That call narrows by `base`, the key with each folded category REMOVED, and then filters each candidate by membership. For a collapsed group, `base` drops the grouped category entirely. So each fold cell scans every record under (other coords), across EVERY item of the category, not just the group's members. The cost per fold cell is O(items in the category), not O(group size). A grid of collapsed small groups is therefore quadratic in the category's size. The old per-member lookup was O(group size) per cell.\n\nMeasured on an Apple M4 Max (16 cores, 64 GB) at load average 50-115, keypress = grid_of(whole grid) + compute_col_widths, min of 10 iterations, 2 alternating rounds. The probe is a dense grid: Day grouped and collapsed on Row, Product on Row, _Measure on Column.\n\n| case | 5932ad74 (pre-#239) | 9948aaf5 | f668ddc9 |\n|---|---|---|---|\n| 364 days in 7-day groups, 20 products, 6 measures | 0.40-0.52 s | 0.92-1.21 s | 1.73-1.75 s |\n| 364 days in pairs, 20 products, 6 measures | 0.54-0.64 s | 0.95-1.09 s | 4.9-6.3 s |\n| 1000 days in pairs, 10 products, 4 measures | 0.44-0.51 s | 0.62-0.65 s | 11.0-15.7 s |\n\nAt 1000 items the fix is ~25x the pre-#239 keypress and ~20x #239's own earlier head. The `grid_projection` bench cannot see this: it has no groups, so its only folds are the column totals, which really do span the whole category.\n\nLikely fix: narrow per span, not per base. For a span whose covered items are few, go through `index[(cat, item)]` for each covered item, intersected with the base. Alternatively, choose between the scan and a member enumeration by comparing the candidate count with the member count. Keep the positions sorted, per gotcha 18. Add a grouped case to benches/grid_projection.rs so the next change is measured against it.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-24T01:05:19Z","created_by":"agent-review-239b","updated_at":"2026-09-24T21:45:07Z","started_at":"2026-09-24T01:12:29Z","closed_at":"2026-09-24T21:45:07Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3tt4","title":"Tab at the rightmost column reopens the browser editor on the stale pre-write cache value, so typing overwrites the value just committed (#227 regression)","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade. This is a regression introduced by that PR.\n\n`resolve::advanced` seeds the reopened editor from the client's cache (`seed_at`). A comment there says \"The new cell is a different cell, so a stale cache is correct here\". That is false for `Advance::Right` at the rightmost column, which clamps `(col+1).min(col_max)` onto the cell that was just written. The TUI's `EnterEditAtCursor` re-seeds from the model after the write. Before #227 the welded trailing `clear-buffer` blanked both seeds, so the two ends agreed on `\"\"`. #227 removes the weld, so the stale seed becomes visible.\n\nMeasured with the lockstep harness (`precise_host`, cursor (0,1), North/Revenue = 2, window (2,2)):\n- `i`, `9`, `Tab`: the browser buffer is `\"2\"` and the TUI buffer is `\"29\"`. At #205's head 7aff3e77, both are `\"\"`.\n- `i`, `9`, `Tab`, `5`, `Enter`: the browser writes **25**, overwriting the 29 the user just committed. The TUI writes 295. At 7aff3e77, both write 5.\n\nIn a real browser the projection that updates the grid does not repair this. `settle_editor` seeds only an ABSENT buffer, and `opened_editor` stays on the stale `\"2\"`.\n\nThe fix belongs in #227. Where the landed cell is the cursor's own cell, seed with the committed value's edit text, the same text the host's `display_value_of` would read after the write. Or derive the seed so it cannot read the pre-write cache for the written cell. Acceptance: a lockstep test that Tabs at the rightmost column and compares buffers, and the typed-extension variant comparing both ends' cell values. Both fail at 8ee5fade.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:02Z","created_by":"agent-review-227-230","updated_at":"2026-09-24T21:44:54Z","started_at":"2026-09-23T15:35:10Z","closed_at":"2026-09-24T21:44:54Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8tk5","title":"ADR 005 clause 3: with no persisted high-water mark, ids are reused after a reload and collide on merge, which defeats the out-of-session identity persistence was for","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nClause 3 has the file record no high-water mark: \"after a reload the counters restart at max id seen + 1\". It argues that reuse is harmless because `document_generation` rejects stale commands. That argument covers the wire only. The ADR's own case for persisting ids (\"Alternatives rejected\") is identity *outside* a session: \"a rename visible as a rename in git diff, and a future reference that survives a restart\". Clause 3 defeats both.\n\n- Reuse after reload. Categories A(3) and B(4). Delete B, save, reload, add C. C gets 4, because the max seen is 3. `git diff` from the first commit to the last shows `B(4)` becoming `C(4)`, which reads as a rename, and an external reference to 4 now silently names C. That is the \"silently names a different thing\" failure clause 3 cites from ADR 002 as the thing ids must never do.\n- Merges. Two branches of one `.improv` each add a category, and both get the next integer. The merged file has a duplicate. Clause 5's repair keeps the first and reassigns the second, so one entity's persisted id changes on merge. Nothing in the file breaks, since nothing refers to an id. But the \"stable across a save and a reload\" promise does not extend to copies or branches, and the ADR does not say so.\n- The generation argument is also per-process: `document_generation` starts again at 0 in a new host process.\n\nMissing open question: whether to persist a high-water mark (for example a `next-id:` line, or per kind), and whether ids should be collision-resistant tokens rather than counters. That bears directly on Q1: integer versus word is not only about readability. Q9's argument (\"ids reset on every load gives back most of what persistence was asked for\") is weaker than stated, because under clause 3 ids can already be reused after a load.\n","status":"open","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:10Z","dependencies":[{"issue_id":"improvise-8tk5","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:51Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8tk5","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:10Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"a2adbdee-78b2-5ec5-8705-95239e34e42b","issue_id":"improvise-8tk5","author":"agent-adr007","text":"agent-adr007 planning (2026-09-24): superseded by the accepted ADR. Revision 8 uses word ids from slugify, and answer 13 (Q10(b), 'Yes, b') accepts reuse across a reload with the counter not persisted. A merge that produces a duplicate id is refused at load (answers 11 and 29). Recommend closing as answered. improvise-cq1q.1 adds one sentence on merges.","created_at":"2026-09-25T04:51:17Z"},{"id":"5aa4ce76-6329-5fe2-a0c1-44adc4fec5ea","issue_id":"improvise-8tk5","author":"Edward Langley","text":"Decisions answered by ADR 007 answer 13; the merge consequence (two branches adding different things under one slug produce a file whose load is refused) is not yet stated -- improvise-cq1q.1 adds it. Close when cq1q.1 merges.","created_at":"2026-09-25T06:35:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} +{"_type":"issue","id":"improvise-80rm","title":"ADR 005 Q3 rests on a false premise: the CSV importer writes 'Name(token)' headers today, and header_decl would silently orphan their data","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nADR 005 open question 3 recommends accepting that \"two rare existing header spellings change meaning on load\" and calls `Q3(draft)` \"a spelling nobody is known to use\". That premise is false: improvise's own CSV importer writes that shape today, from ordinary column headers.\n\nReproduced at 4b880846 (gh/main): `improvise import t.csv --no-wizard` on a CSV with headers `Size(cm),Region(code),Amount` saves\n\n ## Category: Region(code)\n ## Category: Size(cm)\n\nbecause `quote_header` has no case for a parenthesised suffix. Under the ADR's `header_decl` these load as category `Region` with id `code`, and `Size` with id `cm`. Every reference keeps the old spelling (clause 5), so `|Region(code)|: row` and every `## Data` key `|Region(code)|=N, ...` name a category the document no longer declares.\n\nLoading the file the way the new grammar would read it (the headers rewritten to `Region`/`Size`, references untouched) through today's `parse_md` succeeds with no error or warning. The axis lines for the old names are dropped, and every cell stays keyed under a category that does not exist, so none of them is drawn. This is not a rename. It silently orphans all the data and view configuration of a CSV-imported document. `Initial View: Q3(draft)` dangles the same way for a view, and falls back to the default without saying so.\n\nWhat the ADR must do: restate Q3's cost as silent data orphaning, and name the importer as a producer of the shape. Also consider an alternative: rewrite references that still spell the pre-split name on load, or warn and refuse. A sigil is not the only alternative.\n","status":"closed","priority":1,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","updated_at":"2026-09-25T06:35:49Z","closed_at":"2026-09-25T06:35:49Z","close_reason":"Answered by accepted ADR 007 (revision 8); verified by the PR #318 review (issuecomment-5827855684)","dependencies":[{"issue_id":"improvise-80rm","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:50Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-80rm","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:08Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"01a9b2f8-83c4-5f7b-9842-7db54390f4fc","issue_id":"improvise-80rm","author":"agent-adr007","text":"agent-adr007 planning: answered by answer 15 and clause 9. An old-version file keeps whole-line headers, and the writer pipe-quotes a display that would split. Recommend closing. The regression test lives in improvise-cq1q.5 and improvise-cq1q.10.","created_at":"2026-09-25T04:51:19Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-o1y0","title":"A collapsed group's formula re-evaluates every shared reference: exponential in the formula DAG, 3x-300x on the held-key path","description":"Found by the adversarial review of PR #203 at head 2a4f35d7 (improvise-eccl/f2oo).\n\nA collapsed group's formula cell is now evaluated with `Reach::Fold`. The chain is `eval_formula_folded` -\u003e `eval_expr` -\u003e `Expr::Ref` -\u003e `evaluate_fold` -\u003e `eval_formula_folded` again, and nothing memoises it. Before the PR, the fold summed its members' `evaluate_aggregated`, and those hit `formula_cache`. Now every reference to another formula target is re-evaluated in full at every use, and every leaf is a `fold_stored` scan over each covered member's records. The cost is therefore exponential in how often the formula DAG reuses a node. A diamond like `Margin = Profit / Revenue` + `Profit = Revenue - Cost` already pays for it. `grid_of` re-evaluates the whole grid every frame (gotcha 44), so this lands on the held-key path.\n\nMeasured on an Apple M4 Max (Mac16,6), `--release`. The document has 18,000 cells: Month 12 (four quarters) x Region 25 x Product 20 x 3 measures. Product and Month are on Row, `_Measure` on Column, Region on None. Each figure is `GridLayout::new` plus an evaluation of every cell, best of 5. The formulas are `Profit`, `Margin`, `F0 = Revenue + Cost`, and a chain `Fi = F(i-1) + F(i-1)`.\n\n| case | gh/main b4f58e9c | PR 2a4f35d7 |\n|---|---|---|\n| expanded, no chain | 30.1 ms | 30.9 ms |\n| four quarters collapsed, no chain | 31.2 ms | 99.6 ms (3.2x) |\n| collapsed, chain 3 | 30.8 ms | 366 ms (12x) |\n| collapsed, chain 8 | 33.1 ms | 9.83 s (300x) |\n\n`Aggregation::Exact` (nothing on None) was already exponential on main through `evaluate_depth`. A chain of 12 costs 2^13 nodes, 2.1 ms for one cell. That is cheap there, because each leaf is an O(1) `data.get`. The PR routes the common OverHidden + fold case through the same uncached recursion, and makes every leaf a scan.\n\nFix direction: memoise per top-level fold evaluation, with a map keyed by the resolved `CellKey`, owned by the `evaluate_covering` call and threaded through `Reach::Fold`. Circularity detection must survive it, so an in-progress marker should yield \"circular\". `r_circular` in the review's scratch tests is the guard. Evidence to demand: a known-answer test plus a node-count witness, or an injected counter showing linear visits on a diamond chain. Wall-clock time alone is not evidence.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:06:19Z","created_by":"agent-review-203","updated_at":"2026-09-23T15:28:30Z","started_at":"2026-09-23T06:13:13Z","closed_at":"2026-09-23T15:28:30Z","close_reason":"Fixed in PR #203, merged after adversarial re-review at b3379d1; the later changes were evidence-only merges and a docs-only fix to §14. Follow-ups: improvise-h6z1, improvise-d7aj (#232), improvise-wm20.","labels":["adversarial-review pr-203"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-au6c","title":"PR #188's head fails CI: web/app.js now calls client.edit_target, which the wasm-surface ratchet does not list","description":"Found re-reviewing PR #188 at head a5fbd84f. web/tests/bundle/wasm_surface.test.js 'the page still reaches the client through a handful of calls' pins the exact set of client methods web/app.js calls. #188 adds client.edit_target(row, col) and does not add 'edit_target' to that list. The test fails locally (node-test.sh 'web/tests/bundle/*.test.js': actual has 'edit_target', expected does not). It also fails in CI, run 35822048537, job 'Browser harness (OPFS contract + shell)', step 'The page's calls match the bundle it calls', headSha a5fbd84f. This is why that check is red on the PR. The fix is one line in the ratchet list, with a comment naming improvise-2wu3. The arity test in the same file passes: edit_target is declared with 2 args. The miss happened because the local gate list runs 'web/tests/*.test.js' and not 'web/tests/bundle/*.test.js', and the bundle suite needs a built web/pkg.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:42:12Z","created_by":"agent-review-188b","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T05:50:59Z","closed_at":"2026-09-23T06:35:13Z","close_reason":"Fixed in PR #188, merged as de70373 after adversarial re-review at a5fbd84. au6c and 4img were the review-requested fixes; later merges touched evidence only. Follow-up: improvise-vndi.","labels":["adversarial-review","ci","pr-188","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fy9o","title":"Enter twice clears the cell the first Enter advanced onto: the welded clear-buffer blanks the reopened editor","description":"Found on improvise-ixxx. TextEntry::commit welds clear-buffer after every commit key, so after CommitAndAdvance (TUI) or resolve::commit's advance (browser) re-seeds the next cell's editor, the weld empties that buffer. The reopened editor opens BLANK while its captured OpenedEditor seed is the landed cell's text. Pressing Enter again with nothing typed commits an empty buffer, which is ClearCell. Measured on fix/ixxx-untouched-commit: 2x2 pivot, i on (West,Cost), Enter, Enter, and (North,Revenue)=2.0 is gone (get_cell None). Unchanged by ixxx (untouched() only ever suppresses writes). Same harm class as umi4/ixxx: a keystroke that looks like it changes nothing deletes data. Decision needed: is the blank re-entry intended typewriter bulk entry (then the seed captured should be empty so an untouched Enter writes nothing), or should the weld not clear the re-seeded buffer? context/test-evidence/clients.md enter_commits_the_cell_advances_and_re_enters_editing calls the trailing clear odd-looking and mirrors it on purpose.","notes":"Orchestrator raised this to P1. Pressing Enter, Enter with nothing typed deletes a stored value, and that is an ordinary keystroke sequence. It predates #205. Needs a ruling: should the editor that reopens after an advance show the next cell's value (re-seeded), or open blank on purpose? If it should open blank on purpose, then an empty commit of an editor whose seed is non-empty must not mean ClearCell.\nUSER RULING 2026-09-23: the editor that reopens after an advance shows the next cell's current value (re-seeded), not blank. With improvise-ixxx (#205), an unchanged Enter then writes nothing. Implement after #205 merges.","status":"in_progress","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T05:38:58Z","created_by":"agent-ixxx","updated_at":"2026-09-23T14:46:21Z","started_at":"2026-09-23T14:46:21Z","lease_expires_at":"2026-09-23T14:51:21Z","heartbeat_at":"2026-09-23T14:46:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fy9o","title":"Enter twice clears the cell the first Enter advanced onto: the welded clear-buffer blanks the reopened editor","description":"Found on improvise-ixxx. TextEntry::commit welds clear-buffer after every commit key, so after CommitAndAdvance (TUI) or resolve::commit's advance (browser) re-seeds the next cell's editor, the weld empties that buffer. The reopened editor opens BLANK while its captured OpenedEditor seed is the landed cell's text. Pressing Enter again with nothing typed commits an empty buffer, which is ClearCell. Measured on fix/ixxx-untouched-commit: 2x2 pivot, i on (West,Cost), Enter, Enter, and (North,Revenue)=2.0 is gone (get_cell None). Unchanged by ixxx (untouched() only ever suppresses writes). Same harm class as umi4/ixxx: a keystroke that looks like it changes nothing deletes data. Decision needed: is the blank re-entry intended typewriter bulk entry (then the seed captured should be empty so an untouched Enter writes nothing), or should the weld not clear the re-seeded buffer? context/test-evidence/clients.md enter_commits_the_cell_advances_and_re_enters_editing calls the trailing clear odd-looking and mirrors it on purpose.","notes":"Orchestrator raised this to P1. Pressing Enter, Enter with nothing typed deletes a stored value, and that is an ordinary keystroke sequence. It predates #205. Needs a ruling: should the editor that reopens after an advance show the next cell's value (re-seeded), or open blank on purpose? If it should open blank on purpose, then an empty commit of an editor whose seed is non-empty must not mean ClearCell.\nUSER RULING 2026-09-23: the editor that reopens after an advance shows the next cell's current value (re-seeded), not blank. With improvise-ixxx (#205), an unchanged Enter then writes nothing. Implement after #205 merges.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T05:38:58Z","created_by":"agent-ixxx","updated_at":"2026-09-24T21:44:54Z","started_at":"2026-09-23T14:46:21Z","closed_at":"2026-09-24T21:44:54Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-uo2p","title":"ApplyAndClearDrill applies staged edits in hash order: a measure edit plus a category rename on one row writes a ghost record","description":"Found in adversarial review of PR #187 (improvise-hzjo), reproduced at gh/main da2e5ab merged with fc0b13c.\n\n`ApplyAndClearDrill::apply` (crates/improvise-command/src/ui/effect.rs) walks `drill.pending_edits`, a `HashMap\u003c(usize, RecordCol), String\u003e`, in hash order. The edits are not commutative when one row has both a measure edit and a category edit:\n\n- measure first, then rename: the new value is written at the old key, and the rename moves it. Correct.\n- rename first, then measure: the rename moves the row's cells to the new coordinates, then the measure edit writes `row.cell_key(measure)`, the OLD key. That re-creates a ghost record at the old coordinates carrying the new value, and the moved record keeps the stale value.\n\nTwo category edits on one row have the same shape: the second rename finds nothing at the old keys, so one rename is silently dropped, and which one depends on hash order.\n\nRepro: demo.improv, drill `Product/Latte Quarter/Q2` (1 record), stage `Measure(Cost)=999` and `Category(Quarter)=Q9`, then ApplyAndClearDrill. Over 40 fresh Apps: 24 gave `Q2 Cost=None, Q9 Cost=999` (correct), and 16 gave `Q2 Cost=999, Q9 Cost=8800` (a ghost row plus a lost edit). This is reachable from the TUI: edit a measure and then the category column in the same drill row, and leave the drill.\n\nThis is gotcha 18's class (HashSet/HashMap iterated for order), in an effect's `apply` rather than a command. So the determinism law of improvise-hzjo cannot see it: `ApplyAndClearDrill` is a unit struct, and its `Debug` is equal in every App.\n\nFix direction: apply the edits in a defined order that is correct. Measures before category renames within a row, and renames composed rather than each reading the snapshot's old keys. Alternatively, stage by record into an ordered map. The test must use several fresh Apps, since one HashMap iterates the same way twice.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:03:32Z","created_by":"agent-review-182","updated_at":"2026-09-23T06:03:08Z","started_at":"2026-09-23T05:16:52Z","closed_at":"2026-09-23T06:03:08Z","close_reason":"Fixed in PR #204 (RecordRewrite), merged after adversarial review at 92eefee; the only later change was an evidence-only merge of gh/main. Follow-ups: improvise-phg1, improvise-qz9p, improvise-kndq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ixxx","title":"An untouched edit of a text cell that reads as a number (Text 007) commits Number(7): typed() cannot know the cell was text","description":"Found fixing improvise-umi4. After umi4 the edit seed is CellValue::edit_text, which round-trips every Number bit-for-bit, but a Text cell whose text parses as f64 (\"007\", \"1e3\", \"inf\", \"2024\") still does not survive i then Enter with nothing typed: the seed is \"007\" and typed() (improvise-protocol resolve.rs, the one classifier, j7v6) reads it as Number(7.0). Confirmed in both TUI pivot and records mode by adding Text(\"007\") to values_display_rounds in command/cmd/commit.rs tests: stored Some(Number(7.0)). Same shape in the browser (same typed()). Not a seed bug: the seed is exactly the text; the classifier has no way to know the cell was text. Options, each a design decision: (a) a commit whose buffer equals the seed writes nothing (no-op commit is a no-op; also fixes it for every value), (b) seed text cells with an explicit text marker the classifier honours, (c) accept it. (a) looks smallest but must be decided for both front ends at once, since the browser composes SetCell through the same typed().","notes":"Raised to P1 by the orchestrator: it's the same harm as improvise-umi4, a keystroke that should change nothing silently changes data. i then Enter on the text '007' stores Number(7). The umi4 fix made the SEED right ('007'), so this is now purely the commit's typed() reclassifying. The finder's candidate fix: a commit whose buffer equals the seed writes nothing, which is cheap, total, and doesn't change how genuinely typed input is classified. That is a design choice for both front ends (they share typed()), so the #194 reviewer is asked to weigh it.","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T04:47:57Z","created_by":"agent-umi4","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T05:07:44Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","comments":[{"id":"573383d7-6c70-505f-913a-8fab7066a98d","issue_id":"improvise-ixxx","author":"agent-review-194","text":"PR #194 reviewer (agent-review-194) view on option (a). Right, and a follow-up, not #194. After umi4 the seed comes from the STORED raw value, never from the formula result or the display format, so seed != stored happens only when typed(edit_text(v)) != v. That is number-shaped Text (007, 1e3, inf, nan, Infinity, 2024) and a raw CellValue::Error, whose edit_text is 'ERR:msg' and which an untouched commit turns into Text('ERR:msg'). In every such case skipping keeps v, which is what an untouched commit means. The only write it refuses is 'convert this text cell to a number by pressing i Enter', which is not a real intent: type 7. Four design constraints: (1) compare against the seed CAPTURED AT OPEN, not a seed recomputed at commit. The TUI's ctx.display_value and the browser's projected edit_text both move if another client writes the cell mid-edit. Comparing to the current seed makes an untouched editor write its stale text over the other client's value, and the browser's basis claim, taken at compose time, will not reject it. Capturing at open fixes that lost update too. (2) Skip only the WRITE. CommitAndAdvance's advance and re-seed, the mode change, and the records-mode transient-row cleanup (Tab contract) must still run. (3) The browser mouse editor (web/app.js edit/close) calls client.typed_cell unconditionally on commit, so the skip must reach typed_cell_frame too. Put it in one shared protocol function beside typed(), not in JS. (4) It does NOT fix genuinely typed '007' becoming Number(7). That is classification, and it stays out of scope.","created_at":"2026-09-23T05:04:39Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-zlj2","title":"pr-watch judges a healthy PR BLOCKED in the needs: gap before the required browser check registers","description":"Found in adversarial review of PR #186 (head 128a2a0).\n\n`scripts/pr-watch` decides \"checks finished\" as `total \u003e 0 \u0026\u0026 running == 0`. The required check `Browser harness (OPFS contract + shell)` is the `browser` job, which has `needs: build`, and GitHub creates its check run only when `build` completes. On PR #186's own head the check-run IDs show it: the six CI jobs are 107047718800..107047719088 (created 04:41:45), and the harness is 107048169932, created about 04:43:53, after `Build all bundles` completed at 04:43:52. `Test and lint (host)` had already completed at 04:43:45.\n\nSo for a window of about 1-3 s per push, every registered check is COMPLETED and SUCCESS, the required harness check does not exist yet, and GitHub reports MERGEABLE/BLOCKED. A poll that lands in that window (roughly window/interval, so about 5-15% at the default 20 s) gets:\n- on main (before #186): exit 0 on a PR whose required browser check has not run. A false green.\n- with #186: exit 1 on a healthy PR. BLOCKED is refused rather than waited on, so the watch cries wolf.\n\nReproduced with a fixture sequence taken from the real rollup: build IN_PROGRESS/BLOCKED, then build COMPLETED with no harness/BLOCKED, then harness IN_PROGRESS, then CLEAN. The PR-186 script exits 1 after 2 polls.\n\ncontext/code/tooling.md's claim that \"with zero required approvals nothing else produces [BLOCKED] here\" misses this. A required check that is not registered yet is the ordinary case, not a rare one.\n\nSuggested fix: treat MERGEABLE/BLOCKED with no failed check as a wait, the same way UNKNOWN is treated. A required check that never runs then reaches the timeout and exits 3. That is still not 0, and \"not answered yet\" is the accurate report for it. The `table: MERGEABLE/BLOCKED` expectation changes from 1 to 3, and a gap-sequence case is added. The alternative is to read the required contexts from the ruleset and require each one to be present in the rollup.\n","status":"closed","priority":1,"issue_type":"bug","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:47:52Z","created_by":"agent-review-186","updated_at":"2026-09-23T05:05:45Z","started_at":"2026-09-23T04:51:56Z","closed_at":"2026-09-23T05:05:45Z","close_reason":"Fixed in PR #186, merged as 5bcc3ca after adversarial re-review at 04230a7. Follow-ups: improvise-9pfs, improvise-1qaa, improvise-qvpu.","labels":["adversarial-review-pr-186"],"comments":[{"id":"217a26c0-7261-542e-98f7-490035b9a045","issue_id":"improvise-zlj2","author":"agent-tooling3","text":"Fixed on PR #186 at 04230a7","created_at":"2026-09-23T04:59:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} @@ -19,13 +31,14 @@ {"_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.","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":"in_progress","priority":1,"issue_type":"task","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:06:11Z","started_at":"2026-09-23T06:06:11Z","lease_expires_at":"2026-09-23T06:11:11Z","heartbeat_at":"2026-09-23T06:06:11Z","labels":["protocol"],"comments":[{"id":"16e0ef09-54e4-5ca9-86d6-4dddfa573ef0","issue_id":"improvise-fdk1","author":"Edward Langley","text":"agent-review-193-196, reviewing PR #196 (6b3dd8c): #196 fits this ticket and does not foreclose it. It names the coordinate cell CellId::Coordinate but gives it no operand. This ticket's NOTES list 'a records coordinate cell projects id: None, so a client has no surrogate for it to echo' as a blocker. That blocker still stands after #196: a unit variant gives a client nothing to echo. When a producer exists, RenameRecordCoordinate needs the row's RecordHash from the PROJECTION, as Coordinate { record: RecordHash }. A client could also borrow it from a sibling Record cell in the same row, but that would be a second derivation of which cells share a record, which ADR 002 forbids. Growing the variant then is an additive wire change. protocol.md §3's line 'the operand belongs with the command that consumes it' should not be read as saying the command's operand can come from anywhere but the cell.","created_at":"2026-09-23T05:20:39Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} -{"_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.\nagent-wire2 2026-09-22: NOT TAKEN. It is not small, and by its own text it is a design question. (1) It is blocked on the KeyOutcome shape gap: a resolver cannot emit a frame today. (2) It needs a ruling first: should a browser name a path in the HOST's Storage (gotcha 2), or get a download and never name one? The PR #172 forward guard records SaveAs/ExportCsv as Verdict::Gap(improvise-2mqz), so closing this must also delete those two NOT_ON_THE_WIRE entries, or the_verdict_list_names_nothing_that_is_on_the_wire fails. That is only true if the closure adds Command variants of those names; ClientFrame variants would change the verdict to Frame(..).","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-23T04:24:12Z","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-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":"{}"},{"issue_id":"improvise-y11g","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:00Z","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.","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":"closed","priority":1,"issue_type":"task","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:10Z","started_at":"2026-09-23T06:06:11Z","closed_at":"2026-09-24T21:45:10Z","close_reason":"Landed in main via #221 (reviewed; merged 2026-09-24).","labels":["protocol"],"comments":[{"id":"16e0ef09-54e4-5ca9-86d6-4dddfa573ef0","issue_id":"improvise-fdk1","author":"Edward Langley","text":"agent-review-193-196, reviewing PR #196 (6b3dd8c): #196 fits this ticket and does not foreclose it. It names the coordinate cell CellId::Coordinate but gives it no operand. This ticket's NOTES list 'a records coordinate cell projects id: None, so a client has no surrogate for it to echo' as a blocker. That blocker still stands after #196: a unit variant gives a client nothing to echo. When a producer exists, RenameRecordCoordinate needs the row's RecordHash from the PROJECTION, as Coordinate { record: RecordHash }. A client could also borrow it from a sibling Record cell in the same row, but that would be a second derivation of which cells share a record, which ADR 002 forbids. Growing the variant then is an additive wire change. protocol.md §3's line 'the operand belongs with the command that consumes it' should not be read as saying the command's operand can come from anywhere but the cell.","created_at":"2026-09-23T05:20:39Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_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.\nagent-wire2 2026-09-22: NOT TAKEN. It is not small, and by its own text it is a design question. (1) It is blocked on the KeyOutcome shape gap: a resolver cannot emit a frame today. (2) It needs a ruling first: should a browser name a path in the HOST's Storage (gotcha 2), or get a download and never name one? The PR #172 forward guard records SaveAs/ExportCsv as Verdict::Gap(improvise-2mqz), so closing this must also delete those two NOT_ON_THE_WIRE entries, or the_verdict_list_names_nothing_that_is_on_the_wire fails. That is only true if the closure adds Command variants of those names; ClientFrame variants would change the verdict to Frame(..).","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-23T04:24:12Z","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":"{}"},{"issue_id":"improvise-2mqz","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:04:59Z","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":"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":"closed","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-23T04:28:18Z","started_at":"2026-09-23T03:33:55Z","closed_at":"2026-09-23T04:28:18Z","close_reason":"PR #172 MERGED (confirmed at 119f84f), adversarially reviewed then re-reviewed after fixes (merge). h3y7: the forward vocabulary guard (ui/reduce/vocabulary_is_complete.rs) requires every Effect to be a same-named Command/ModelCommand variant or carry a NOT_ON_THE_WIRE verdict (Command/Frame/Off/Gap), checked in both directions. hkbc: position_free destructures every ModelCommand with no '..'. c7mu: one shared scanner, improvise_protocol::source_scan::production_code, blanks comments and literals and drops test code for BOTH vocabulary guards. 94x2: name_after skips attributes, so an attributed simple_effect! is still seen; re-review injected three attribute spellings and all were caught. Residual: improvise-59q8 (P3, the scanner's latent line-scan holes).","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-cgqt","title":"Import collision policy should be a wizard step, not a fixed default","description":"improvise-2cw9 decided that two source rows landing on one coordinate tuple are\nSUMMED and reported. That decision is recorded on the bead and is right as a\ndefault -- it is what a pivot table does and what measure_agg defaults to.\n\nIt is WRONG for a measure the user later sets to Avg: the value is already\npre-summed, so the average is taken over the wrong denominator. The warning is\nwhat currently keeps that from being invisible.\n\nThe user is the only one who knows which they want. The shape is a wizard step\nthat asks, on the field where the collision was detected -- not a change to the\ndefault, and not a silent guess from the field's inferred kind.\n\nSearch `duplicate`, `collision`, `aggregate` before filing anything adjacent.\n","status":"closed","priority":1,"issue_type":"feature","assignee":"agent-cgqt","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:43Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","started_at":"2026-09-25T01:10:23Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["import","ux"],"dependencies":[{"issue_id":"improvise-cgqt","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:28Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"0a4ce58b-d381-5b92-bc64-ba1e32c2f5f4","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER DECISION 2026-09-25 (answered in orchestrator session, after an Etrade transactions import summed 31 colliding cells; user called it critical): when import rows collide on every coordinate, the wizard ASKS, offering (a) sum them (today's default, 2cw9), (b) keep them separate by adding a row-number category, (c) cancel/go back. Chosen over 'always keep every row via a hidden virtual row category' and over 'refuse the import'. Raised P3 -\u003e P1. Also in scope: the Confirm step truncates the collision warning to one line, so the user cannot see which rows collided -- the step must show them. Open detail the implementer must surface, not guess: option (b) adds a REGULAR category, and the reported import already had 12 (MAX_CATEGORIES), so (b) cannot be offered at the cap as described.","created_at":"2026-09-25T01:10:07Z"},{"id":"5c2662ac-64d5-58fa-8a75-4200c9c180a7","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER REFINEMENT 2026-09-25, verbatim: 'ask during import and offer to generate a disambiguating category.' Option (b) is therefore a generated category whose job is only to tell colliding rows apart (preferred: occurrence index within each collision group, items 1..n in source order), with a user-editable suggested name.","created_at":"2026-09-25T01:10:17Z"},{"id":"98b45303-397e-559a-a284-cb51ff3ff6b3","issue_id":"improvise-cgqt","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'a message like this should wrap not truncate so I can read the whole thing.' In scope here for every import-wizard warning; other surfaces to be filed separately.","created_at":"2026-09-25T01:10:48Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_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} {"_type":"issue","id":"improvise-86nw","title":"Worker startup failure leaves page in silent dead state","description":"## Summary\n\nIf worker `started` rejects (e.g. WASM `init()`/`start()` throws for a non-OPFS reason), `.catch()` only logs. The `onFrame` listener stays installed and calls `handle()` while `server` is null. Frames accumulate in the error log with no UI indication beyond the eventual watchdog; if the watchdog never arms (worker never posts), the page sits dead.\n\n## Expected / Actual\n\n- **Expected:** Startup failure surfaces a loud, specific status (and stops accepting frames / arms watchdog).\n- **Actual:** Console-only catch; silent dead UI until generic watchdog (if any).\n\n## Distinct from improvise-16af\n\nimprovise-16af tracks intermittent blank grid from OPFS `SyncAccessHandle` / `NotAllowedError` conflicts. This bead is the general **startup-without-OPFS-conflict** path: WASM init/start rejection leaves `server === null` with only a logged catch. Related symptom, different root.\n\n## Location\n\n`web/worker.js` ~L43-60\n\n## PR / Attribution\n\nPR #100 F03. 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:55Z","closed_at":"2026-09-21T21:58:55Z","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\nstart() rejecting for any reason posts BootFailed; the page renders it and disarms the watchdog.","external_ref":"gh-pr-100-F03","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-86nw","depends_on_id":"improvise-16af","type":"relates-to","created_at":"2026-09-21T20:48:19Z","created_by":"external-grok-bot","metadata":"{}"},{"issue_id":"improvise-86nw","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:50Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"91dcae3c-8f35-56e5-b794-18191771873a","issue_id":"improvise-86nw","author":"external-grok-bot","text":"Related to improvise-16af (OPFS handle conflict blank grid). This bead covers non-OPFS WASM startup rejection only. — PR #100","created_at":"2026-09-21T20:48:19Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} @@ -74,10 +87,10 @@ {"_type":"issue","id":"improvise-6kj","title":"Pest parse errors lack file:line context for users","description":"persistence/mod.rs:294 wraps pest errors as 'Parse error: {e}' which shows Pair offsets like 'Pair(6..12), expected \"=\", found \",\"' instead of a line preview. Users debugging a malformed .improv file have no way to find the offending line. Fix: extract (line, col) from pest::error::Error via line_col(), include the line contents and surrounding context in the wrapped error.","acceptance_criteria":"A syntactically invalid .improv file produces an error containing the source line number (and file path when loading from disk); pest spans already carry this. Test asserts the error message contains the expected line:col.","status":"closed","priority":1,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:13Z","created_by":"Ed L","updated_at":"2026-06-10T04:45:53Z","closed_at":"2026-06-10T04:45:53Z","close_reason":"Fixed in 3418162/47ad9e6 (test-first): export_csv via csv::Writer honoring number format; malformed numbers -\u003e CellValue::Error; formula [Category] suffix moved into pest grammar; parse errors report line/col + offending line. 155 io tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hmu","title":"TAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor","description":"Current records view data entry is awkward. On last row + rightmost cell: TAB should add record below + go to first cell of new row + edit. Enter should add below + stay in same column + edit. 'o' should insert below cursor like Vim (not always at end). Red-green-refactor: add failing test first, then minimal implementation in layout.rs + related command/effect code, then refactor duplication. Builds on improvise-rbv and improvise-c5v.","status":"closed","priority":1,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:37:52Z","created_by":"Edward Langley","updated_at":"2026-04-16T05:08:00Z","closed_at":"2026-04-16T05:08:00Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9x6","title":"Worker-hosted server bundle (wasm, MessageChannel transport)","description":"New wasm bundle that hosts the full App + command pipeline + projection layer + VFS persistence inside a browser worker. Communicates with the main-thread side via a MessageChannel carrying Command messages in the same shape as the websocket protocol. The worker-server is the 'server' half of the standalone deployment — structurally it mirrors improvise-ws-server but swaps tokio + tungstenite + std::fs for wasm + postMessage + OPFS. Both ws-server and worker-server reuse the same projection layer (improvise-cqq); only the transport and persistence backend differ.","design":"crates/improvise-worker-server/. Depends on improvise-core, improvise-formula, improvise-command, improvise-io (persistence half), improvise-protocol, and the OPFS Storage implementation from improvise-i34. wasm-bindgen entry points: init(opts) initializes App and VFS-backed Storage, loads an initial file from OPFS if present; on_message(serialized_command) deserializes the Command, feeds it through the App + projection layer, posts any outbound projection commands back via self.postMessage(). OPFS is async; handle by returning JsPromise from on_message or by queuing outbound messages and letting the event loop deliver them. Projection layer (improvise-cqq) is reused wholesale — transport-agnostic, already tracks per-session viewport state. The only new code specific to this issue is (a) wasm-bindgen bootstrap, (b) MessageChannel binding via self.onmessage / self.postMessage, (c) wiring VFS/OPFS storage into the persistence layer. Worker choice: Dedicated Worker first. Service Worker later if PWA install is desired.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Worker bundle runs the full App end-to-end: loads file from OPFS, processes commands, persists changes, emits projections. (3) Size budget: under 3 MB compressed. (4) Driven by a test harness that simulates main-thread postMessage traffic.","notes":"Companion to improvise-djm — two halves of the standalone deployment. Depends on improvise-cqq (projection layer), improvise-cqi (protocol), improvise-ywd (wasm compat), improvise-6mq (VFS abstraction), improvise-i34 (OPFS impl). Effectively a wasm-flavored analogue of improvise-q08 (ws-server); both wrap the same projection layer with a different transport + persistence.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:29Z","created_by":"spot","updated_at":"2026-09-20T02:10:39Z","closed_at":"2026-09-20T02:10:39Z","close_reason":"Done. The server as a wasm blob in a Web Worker: same App, same Sessions, same Host, behind self.onmessage/self.postMessage with OPFS underneath. Closed with --force because the graph still lists improvise-xf1 (browser verification of OpfsFs), which is DEFERRED by user decision -- that block is waived, not satisfied.\n\nAC 1 met (wasm32 clean, builds through wasm-pack, added to the CI wasm job). AC 3 met with room: 1,334,892 bytes raw / 496,965 GZIPPED plus 6,700 of JS glue -- about 492 KB against a 3 MB budget; with the client's unchanged 122,599 the pair is ~600 KB. AC 4 met: tests/post_message.rs drives a real Worker with nothing but the JSON a MessagePort carries.\n\nAC 2 IS MET HOST-SIDE, NOT BROWSER-SIDE, and the line is exact. Tested: worker.rs (16t), vfs.rs, post_message.rs (6t) -- handshake, Addressed filter, rejection path, persist trigger, seeding and list are all on this side. Untested: bindings.rs (cfg wasm32) and web/worker.js -- compile-checked, node --check-ed, all three calls confirmed in the generated .d.ts with matching arity, nothing more. createSyncAccessHandle exists in no harness, so OPFS ITSELF REMAINS NEVER RUN: if persistence misbehaves in a browser it is the first place to look.\n\nHOST WAS MOVED, NOT MIRRORED -- the one non-additive call, sanity-checked at merge. Host is App + Sessions + handle, contains no socket, and lived in a tokio crate the worker cannot depend on. It now lives in improvise_command::ui::host beside Sessions; ws-server re-exports so every path resolves; registry stayed behind because a worker hosts ONE document. Re-implementing the loop would have been the mirror improvise-xks6 had just deleted for the frames, and a drifting Applied/Broadcast match is invisible until a client sees it. Verified at merge: host.rs imports only crate::ui::{app,project} and improvise_protocol, and improvise-command's dependency list is unchanged.\n\nTHE DOCUMENT IS NAMED OUT OF BAND, in the worker's URL -- the analogue of the socket naming it in the ws:// path. NO FOURTH ClientFrame. Per the user's ruling mid-flight, loading is THE VFS, not a bespoke entry point: a seed is a Storage::write read back like any other document, so a fetched demo, an upload and a saved file share one parse path and a seed never clobbers an edit.\n\nClientFrame::Key IS LEFT ANSWERING NOTHING, and the brief's premise that a worker could resolve it was WRONG for a reason worth keeping. A worker CAN call reduce_key; the blocker is downstream and identical on both transports -- NOTHING IN THE PROJECTION STREAM CARRIES THE HOST'S OWN ViewState. A client's slice moves only by folding Commands, and project emits document projections, a grid diff and a basis, none of which is a mode or cursor change. So resolving a key host-side does not under-deliver, it DIVERGES: the host believes it is in Editing while the client draws Normal, and viewport() reads the mirror's cursor so the grid would not even scroll. That is improvise-bpq2, now a DEMONSTRATED property rather than prose -- a_key_resolved_on_the_host_moves_state_no_client_can_hear fires reduce_key against a real App and asserts the mirror did not follow.\n\nFOUR REAL OBSTACLES, AND NONE WAS OPFS: Host in the wrong crate; Save unreachable from the wire; dirty never set by a wire edit; the async seam unreachable through a Box. THREE OF THE FOUR ARE GAPS IN THE SHARED LAYER THAT THE SOCKET SERVER HAS TOO -- it never noticed, because its documents are opened from a CLI and saved by a human pressing a key in a TUI that is not there. Filed separately. Being the second real consumer of an abstraction is what audits it.\n\n1155 passing (+22), clippy/fmt clean, wasm32 clean on all SEVEN bundle crates, actionlint clean. Delivery stayed cheap: two lines in build.sh, one cp -rf plus one cp in the assemble step.","dependencies":[{"issue_id":"improvise-9x6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:40:11Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-xf1","type":"blocks","created_at":"2026-08-31T23:11:01Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-9x6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:12Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"INHERITS improvise-d31's AC 4 -- a bundled demo model -- which could not be satisfied there. TWO DISTINCT SUB-GAPS, both real:\n\n(a) NOTHING IN THE BROWSER LOADS A .improv AT ALL. The ws-server takes the file on its CLI; the worker needs an 'init from these bytes / this URL' entry before a bundled file has any consumer. So this is genuinely after improvise-9x6, not merely sequenced after it.\n\n(b) examples/demo.improv IS UNUSABLE AS A DEMO and the fix is not an axis flip. Customer and Date sit on the None axis, so every cell is an aggregate and nothing is editable -- correct behaviour, useless demo. Moving Date to page does NOT obviously fix it: Product x Region by Date_Month x Measure with Customer and Date fixed can still aggregate. This needs a model BUILT AND CHECKED against the 'is every visible cell a Record' question, not an axis changed and hoped for. The check is cheap now that ADR 002 exists -- a cell is settable iff its CellId is Record -- so write the assertion first and make a model that satisfies it.\nGROOMED 2026-09-20 after wave 1. Most of this milestone is now DEMONSTRABLE BY TEST rather than by hand, which is a better outcome than a manual sign-off, and the remainder is narrowed.\n\nDEMONSTRATED by harness/ (improvise-vvl3), running in CI and able to fail the build:\n * the seeded demo document drawing in the STANDALONE WORKER deployment -- no server process anywhere;\n * click-to-edit committing through the host and the host's formatted value coming back;\n * PERSISTENCE ACROSS A RELOAD at the storage layer, including that a committed delete stays deleted -- which was the one acceptance criterion no test in this repo could structurally reach;\n * every #status message the shell must write.\n\nNOT DEMONSTRATED, and one of them is a real gap in the criteria rather than in the harness:\n * 'ADD A FORMULA' -- the browser has no formula entry at all. That is the honest remaining item and it is a FEATURE, not a verification gap. It wants its own bead before this milestone can be called met as written.\n * Safari (Playwright's WebKit has no OPFS -- improvise-xf1);\n * two tabs;\n * bindings.rs and web/worker.js as CODE PATHS -- the harness drives OpfsFs directly and never instantiates the shim, so bindings.rs is now the dark corner OpfsFs used to be.\n\nAlso note improvise-plcx: web/demo.improv has no groups and one category per axis, so the harness never exercises the multi-level and group paths PR #23 added.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6. Each acceptance\ncriterion walked against the DEPLOYED page, the harness and the code. Two are\nmet with evidence, one is met and was previously only asserted, and two are\nnot met — the ticket stays OPEN and the reason is (1), not paperwork.\n\n(1) ALL TEST PLAN STEPS PASS IN CHROME AND FIREFOX — NOT MET, two reasons.\n First, test-plan step 5, \"add a formula\", has no implementation: a browser\n can ENTER FormulaEdit and cannot commit it (improvise-fdt6, which this\n ticket already depends on). Second, \"and Firefox\" is not true even for the\n steps that do work. harness/playwright.config.js runs shell.spec.js on\n CHROMIUM ONLY; Firefox and WebKit are restricted to opfs.spec.js by\n `testMatch`, and WebKit skips that on a runtime OPFS probe. So the whole\n shell — the page drawing the demo out of the worker, click-to-edit\n committing — is verified on exactly one engine. That second half is NOT\n recorded anywhere in this ticket's earlier annotation, which said \"Chrome\n and Firefox\" was covered by the harness.\n\n(2) PAGE LOAD TO INTERACTIVE UNDER 3 SECONDS — MET, measured rather than\n argued. Three cold loads of https://fiddlerwoaroof.github.io/improvise/\n driven with Playwright Chromium, a freshly launched browser per run (so\n empty HTTP cache and empty OPFS), timing `goto` until `#grid table` AND the\n first `#grid td.editable` are both visible: 1172 ms, 278 ms, 408 ms, zero\n page errors, `#status` reading \"in-page worker · demo.improv\" and 12 cells\n drawn. The honest caveat: that is a fast connection with no throttling, so\n it bounds instantiation and handshake, not \"a typical connection\". The\n transfer it has to make is small enough that this is unlikely to be the\n binding constraint — see (3).\n\n(3) WASM BUNDLE UNDER 3 MB COMPRESSED — MET, by an order of magnitude, and\n measured OFF THE DEPLOYED SITE rather than off a local build. Requested\n with `Accept-Encoding: gzip` from GitHub Pages:\n improvise_wasm_client_bg.wasm 151,522 B gzipped\n improvise_worker_server_bg.wasm 547,952 B gzipped\n both .js glue files 10,019 B\n app.js + worker.js + style.css 7,741 B\n index.html + demo.improv 2,161 B\n Two wasm binaries total 699 KB gzipped; the whole site is about 719 KB.\n\n(4) SCREENCAST OR SCREENSHOT SERIES ATTACHED TO THE ISSUE — NOT MET, and not\n meetable as written: bd has no attachments. A full-page screenshot of the\n live site was captured during this pass and the numbers in (2) came off the\n same run. The earlier annotation's argument stands and is the right one —\n harness/tests/shell.spec.js proves the path in CI where a screenshot proves\n a moment — so the useful outcome is to REWRITE this criterion, not to\n satisfy it.\n\n(5) README UPDATED WITH LINK TO LIVE DEMO — MET. README's \"In the browser\"\n section carries https://fiddlerwoaroof.github.io/improvise/ and describes\n the no-server deployment. Wave 2 also corrected the same section's claim\n that a deploy waits on \"cargo test and the wasm32 check\": it waits on the\n browser harness too (`needs: [test, wasm, browser, pages-build]`).\n\nREMAINING CONTENT OF THIS TICKET: improvise-fdt6, plus deciding whether \"and\nFirefox\" in (1) is a requirement or a date. If it is a requirement it is a\n`testMatch` change in harness/playwright.config.js and the reason it is\nChromium-only today (routeWebSocket being slower and flakier across three\nengines) applies only to the FOUR ?ws= specs, not to the two standalone ones.\nCORRECTION TO THE NOT-DEMONSTRATED LIST, from adversarial review of PR #47.\n\nThat list says \"bindings.rs and web/worker.js as CODE PATHS\" are not\ndemonstrated, \"the harness drives OpfsFs directly and never instantiates the\nshim\". That is no longer true, and has not been since improvise-vvl3.\n\n`harness/tests/shell.spec.js`'s standalone tests load /app/index.html with no\n?ws=, which spawns the real worker: app.js -\u003e worker.js -\u003e the worker bundle's\n`start()` in bindings.rs -\u003e `OpfsFs::open().await`. Both `bindings.rs` AND\n`web/worker.js` are therefore executed under Chromium on every CI run, and the\nspec's own comment says so. Verified by running it at 2ccd21f and watching\n/app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm fetched and\nthe grid drawn from OPFS.\n\nSo one item on that list is met rather than unmet. What genuinely remains\nunmet: Safari (improvise-xf1), two tabs, and \"add a formula\" (improvise-fdt6).\nFirefox is improvise-zkkw — a second ENGINE for the shim, which is a real gap\nand a different claim from \"never run at all\".\n\nA criterion recorded unmet when it is met is the same defect as one recorded met\nwhen it is not: both are a claim nobody rechecked.\nURL CORRECTION to the verification annotation above.\n\nThat annotation cites `https://fiddlerwoaroof.github.io/improvise/` as what was\nmeasured for criteria (2) page-load-to-interactive, (3) bundle size and (5) the\nREADME link. Those measurements were taken against the browser APP, and were\ncorrect when taken — but this same PR then moved the app, on a user ruling\n(improvise-kh8): the site root is now the LANDING PAGE and the app is at\n`https://fiddlerwoaroof.github.io/improvise/app/`.\n\nThe measurements stand — 1172/278/408 ms to an editable grid, 699 KB gzipped for\nthe two wasm binaries — and every URL that produced them now names the wrong\npage. Read every bare `…github.io/improvise/` in that annotation as\n`…github.io/improvise/app/`. Anyone re-running them against the root will be\ntiming four asciinema players instead, and would get a number that looks\nplausible and means nothing.\nGROOM 2026-09-21: this bead read READY -- every dependency closed -- while its own criterion (1) was failing. Two blockers added, both found today and neither existing when the tree was last true:\n\n - improvise-1wft: the browser's boot fails silently for five distinct causes. One of them (improvise-i0v9) is the SHIPPED DEMO showing a blank grid with a reassuring status line for 8 seconds and then blaming the browser. A demo that does that has not met 'all test plan steps pass'.\n - improvise-p7lh: the shipped demo has a page axis and the browser draws no page bar, and no column totals.\n\nCriterion (1) also says Chrome AND FIREFOX. harness/tests/shell.spec.js is CHROMIUM ONLY by deliberate configuration (engine-independent logic; routeWebSocket is slower and flakier across three), so nothing currently runs the shell on Firefox. improvise-zkkw is closed and recorded that gap; the criterion still names Firefox. Decide whether to run the shell spec on Firefox or to amend the criterion -- do not close this bead by quietly reading 'Chrome' alone.\n\nCriteria (2) load under 3s, (3) wasm under 3MB compressed, (4) screencast and (5) README link are unverified here and are cheap to measure. They are the actual remaining work on this bead.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-09-21T21:27:14Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-fdt6","type":"blocks","created_at":"2026-09-20T16:21:00Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-p7lh","type":"blocks","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":7,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-bck","title":"Standalone web MVP: end-to-end offline demo","description":"Final milestone of the standalone epic. Open the deployed static site (or run it locally from file://), load a bundled demo .improv file, edit cells, add a formula, save to OPFS, reload the page, verify persistence. No server process running anywhere. This is the architectural payoff — improvise running entirely in the browser.","design":"Architecture under test: main-thread wasm-client (thin client), dedicated worker hosting worker-server bundle, MessageChannel transport between them, OPFS persistence via the VFS abstraction. Test plan: (1) Visit the deployed GH Pages URL or run the shell locally. (2) Bundled demo file loads automatically via the worker on init. (3) Navigate with arrow keys — view effects dispatched locally in the thin-client wasm, no postMessage round-trip. (4) Enter edit mode, type a number, commit — command flows to worker via MessageChannel, worker updates model + persists to OPFS, projection command flows back. (5) Add a formula — same path, plus formula recompute in worker. (6) Trigger save (explicit save to user-picked path via File System Access API, or implicit autosave to OPFS). (7) Close the tab, reopen the URL, verify edits persisted. (8) Upload a different .improv file via file picker, verify it opens. Performance: cursor moves under 16ms (local, no worker hop); edit commits under 100ms (worker hop + OPFS write).","acceptance_criteria":"(1) All test plan steps pass in Chrome and Firefox. (2) Page load to interactive under 3 seconds on a typical connection. (3) Wasm bundle under 3 MB compressed. (4) Screencast or screenshot series documenting the demo attached to the issue. (5) README updated with link to live demo.","notes":"INHERITS improvise-d31's AC 4 -- a bundled demo model -- which could not be satisfied there. TWO DISTINCT SUB-GAPS, both real:\n\n(a) NOTHING IN THE BROWSER LOADS A .improv AT ALL. The ws-server takes the file on its CLI; the worker needs an 'init from these bytes / this URL' entry before a bundled file has any consumer. So this is genuinely after improvise-9x6, not merely sequenced after it.\n\n(b) examples/demo.improv IS UNUSABLE AS A DEMO and the fix is not an axis flip. Customer and Date sit on the None axis, so every cell is an aggregate and nothing is editable -- correct behaviour, useless demo. Moving Date to page does NOT obviously fix it: Product x Region by Date_Month x Measure with Customer and Date fixed can still aggregate. This needs a model BUILT AND CHECKED against the 'is every visible cell a Record' question, not an axis changed and hoped for. The check is cheap now that ADR 002 exists -- a cell is settable iff its CellId is Record -- so write the assertion first and make a model that satisfies it.\nGROOMED 2026-09-20 after wave 1. Most of this milestone is now DEMONSTRABLE BY TEST rather than by hand, which is a better outcome than a manual sign-off, and the remainder is narrowed.\n\nDEMONSTRATED by harness/ (improvise-vvl3), running in CI and able to fail the build:\n * the seeded demo document drawing in the STANDALONE WORKER deployment -- no server process anywhere;\n * click-to-edit committing through the host and the host's formatted value coming back;\n * PERSISTENCE ACROSS A RELOAD at the storage layer, including that a committed delete stays deleted -- which was the one acceptance criterion no test in this repo could structurally reach;\n * every #status message the shell must write.\n\nNOT DEMONSTRATED, and one of them is a real gap in the criteria rather than in the harness:\n * 'ADD A FORMULA' -- the browser has no formula entry at all. That is the honest remaining item and it is a FEATURE, not a verification gap. It wants its own bead before this milestone can be called met as written.\n * Safari (Playwright's WebKit has no OPFS -- improvise-xf1);\n * two tabs;\n * bindings.rs and web/worker.js as CODE PATHS -- the harness drives OpfsFs directly and never instantiates the shim, so bindings.rs is now the dark corner OpfsFs used to be.\n\nAlso note improvise-plcx: web/demo.improv has no groups and one category per axis, so the harness never exercises the multi-level and group paths PR #23 added.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6. Each acceptance\ncriterion walked against the DEPLOYED page, the harness and the code. Two are\nmet with evidence, one is met and was previously only asserted, and two are\nnot met — the ticket stays OPEN and the reason is (1), not paperwork.\n\n(1) ALL TEST PLAN STEPS PASS IN CHROME AND FIREFOX — NOT MET, two reasons.\n First, test-plan step 5, \"add a formula\", has no implementation: a browser\n can ENTER FormulaEdit and cannot commit it (improvise-fdt6, which this\n ticket already depends on). Second, \"and Firefox\" is not true even for the\n steps that do work. harness/playwright.config.js runs shell.spec.js on\n CHROMIUM ONLY; Firefox and WebKit are restricted to opfs.spec.js by\n `testMatch`, and WebKit skips that on a runtime OPFS probe. So the whole\n shell — the page drawing the demo out of the worker, click-to-edit\n committing — is verified on exactly one engine. That second half is NOT\n recorded anywhere in this ticket's earlier annotation, which said \"Chrome\n and Firefox\" was covered by the harness.\n\n(2) PAGE LOAD TO INTERACTIVE UNDER 3 SECONDS — MET, measured rather than\n argued. Three cold loads of https://fiddlerwoaroof.github.io/improvise/\n driven with Playwright Chromium, a freshly launched browser per run (so\n empty HTTP cache and empty OPFS), timing `goto` until `#grid table` AND the\n first `#grid td.editable` are both visible: 1172 ms, 278 ms, 408 ms, zero\n page errors, `#status` reading \"in-page worker · demo.improv\" and 12 cells\n drawn. The honest caveat: that is a fast connection with no throttling, so\n it bounds instantiation and handshake, not \"a typical connection\". The\n transfer it has to make is small enough that this is unlikely to be the\n binding constraint — see (3).\n\n(3) WASM BUNDLE UNDER 3 MB COMPRESSED — MET, by an order of magnitude, and\n measured OFF THE DEPLOYED SITE rather than off a local build. Requested\n with `Accept-Encoding: gzip` from GitHub Pages:\n improvise_wasm_client_bg.wasm 151,522 B gzipped\n improvise_worker_server_bg.wasm 547,952 B gzipped\n both .js glue files 10,019 B\n app.js + worker.js + style.css 7,741 B\n index.html + demo.improv 2,161 B\n Two wasm binaries total 699 KB gzipped; the whole site is about 719 KB.\n\n(4) SCREENCAST OR SCREENSHOT SERIES ATTACHED TO THE ISSUE — NOT MET, and not\n meetable as written: bd has no attachments. A full-page screenshot of the\n live site was captured during this pass and the numbers in (2) came off the\n same run. The earlier annotation's argument stands and is the right one —\n harness/tests/shell.spec.js proves the path in CI where a screenshot proves\n a moment — so the useful outcome is to REWRITE this criterion, not to\n satisfy it.\n\n(5) README UPDATED WITH LINK TO LIVE DEMO — MET. README's \"In the browser\"\n section carries https://fiddlerwoaroof.github.io/improvise/ and describes\n the no-server deployment. Wave 2 also corrected the same section's claim\n that a deploy waits on \"cargo test and the wasm32 check\": it waits on the\n browser harness too (`needs: [test, wasm, browser, pages-build]`).\n\nREMAINING CONTENT OF THIS TICKET: improvise-fdt6, plus deciding whether \"and\nFirefox\" in (1) is a requirement or a date. If it is a requirement it is a\n`testMatch` change in harness/playwright.config.js and the reason it is\nChromium-only today (routeWebSocket being slower and flakier across three\nengines) applies only to the FOUR ?ws= specs, not to the two standalone ones.\nCORRECTION TO THE NOT-DEMONSTRATED LIST, from adversarial review of PR #47.\n\nThat list says \"bindings.rs and web/worker.js as CODE PATHS\" are not\ndemonstrated, \"the harness drives OpfsFs directly and never instantiates the\nshim\". That is no longer true, and has not been since improvise-vvl3.\n\n`harness/tests/shell.spec.js`'s standalone tests load /app/index.html with no\n?ws=, which spawns the real worker: app.js -\u003e worker.js -\u003e the worker bundle's\n`start()` in bindings.rs -\u003e `OpfsFs::open().await`. Both `bindings.rs` AND\n`web/worker.js` are therefore executed under Chromium on every CI run, and the\nspec's own comment says so. Verified by running it at 2ccd21f and watching\n/app/worker.js and /app/worker-pkg/improvise_worker_server_bg.wasm fetched and\nthe grid drawn from OPFS.\n\nSo one item on that list is met rather than unmet. What genuinely remains\nunmet: Safari (improvise-xf1), two tabs, and \"add a formula\" (improvise-fdt6).\nFirefox is improvise-zkkw — a second ENGINE for the shim, which is a real gap\nand a different claim from \"never run at all\".\n\nA criterion recorded unmet when it is met is the same defect as one recorded met\nwhen it is not: both are a claim nobody rechecked.\nURL CORRECTION to the verification annotation above.\n\nThat annotation cites `https://fiddlerwoaroof.github.io/improvise/` as what was\nmeasured for criteria (2) page-load-to-interactive, (3) bundle size and (5) the\nREADME link. Those measurements were taken against the browser APP, and were\ncorrect when taken — but this same PR then moved the app, on a user ruling\n(improvise-kh8): the site root is now the LANDING PAGE and the app is at\n`https://fiddlerwoaroof.github.io/improvise/app/`.\n\nThe measurements stand — 1172/278/408 ms to an editable grid, 699 KB gzipped for\nthe two wasm binaries — and every URL that produced them now names the wrong\npage. Read every bare `…github.io/improvise/` in that annotation as\n`…github.io/improvise/app/`. Anyone re-running them against the root will be\ntiming four asciinema players instead, and would get a number that looks\nplausible and means nothing.\nGROOM 2026-09-21: this bead read READY -- every dependency closed -- while its own criterion (1) was failing. Two blockers added, both found today and neither existing when the tree was last true:\n\n - improvise-1wft: the browser's boot fails silently for five distinct causes. One of them (improvise-i0v9) is the SHIPPED DEMO showing a blank grid with a reassuring status line for 8 seconds and then blaming the browser. A demo that does that has not met 'all test plan steps pass'.\n - improvise-p7lh: the shipped demo has a page axis and the browser draws no page bar, and no column totals.\n\nCriterion (1) also says Chrome AND FIREFOX. harness/tests/shell.spec.js is CHROMIUM ONLY by deliberate configuration (engine-independent logic; routeWebSocket is slower and flakier across three), so nothing currently runs the shell on Firefox. improvise-zkkw is closed and recorded that gap; the criterion still names Firefox. Decide whether to run the shell spec on Firefox or to amend the criterion -- do not close this bead by quietly reading 'Chrome' alone.\n\nCriteria (2) load under 3s, (3) wasm under 3MB compressed, (4) screencast and (5) README link are unverified here and are cheap to measure. They are the actual remaining work on this bead.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:38Z","created_by":"spot","updated_at":"2026-09-21T21:27:14Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-bck","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:17Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:16Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-fdt6","type":"blocks","created_at":"2026-09-20T16:21:00Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-p7lh","type":"blocks","created_at":"2026-09-21T21:31:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-tm6","type":"parent-child","created_at":"2026-09-25T01:07:37Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bck","depends_on_id":"improvise-zkkw","type":"blocks","created_at":"2026-09-20T18:02:34Z","created_by":"wave2-p-milestones","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-d31","title":"Static HTML shell + GitHub Pages deploy workflow","description":"HTML/JS shell that hosts the standalone wasm deployment. Registers the worker-server (dedicated worker for MVP, service worker as a later PWA enhancement), instantiates the MessageChannel transport, loads the main-thread wasm-client, wires them together, and presents the DOM renderer. Also publishes the whole bundle to GitHub Pages via a build + deploy workflow so anyone can open improvise in a browser with no install.","design":"docs/ or web/ directory with index.html (#app div), main.js (main-thread wasm loader + worker registration + MessageChannel setup + keyboard event wiring), worker.js (loads the worker-server wasm bundle and handles postMessage), style.css. No websocket code — transport is local MessageChannel only. GitHub Actions: on push to main, build both wasm bundles (main-thread and worker) with cargo + wasm-bindgen, run wasm-opt for size, assemble into a static site, push to gh-pages branch. Path-filtered to skip if neither bundle changed. Worker registration: start with Dedicated Worker (simpler lifecycle, same-tab scope) until PWA install requirements justify Service Worker complexity.","acceptance_criteria":"(1) Static site loads improvise in a modern browser with no backend. (2) GitHub Actions workflow builds and deploys on every main push. (3) Deployed URL is listed in the README. (4) Demo .improv file is bundled so first-time visitors see something, not an empty grid.","notes":"PROGRESS 2026-09-20 -- the DELIVERY half is done and landed; the ticket stays OPEN because the half it is named for (a static shell that works) genuinely does not, and bd is right to refuse the close while improvise-djm blocks it.\n\nDONE: pages-build/pages-deploy appended to the EXISTING ci.yml, so the deploy is a needs: edge on [test, wasm, pages-build] rather than a workflow_run dance. web/build.sh runs UNCHANGED, so there stays one answer to how the bundle is built and a local build is evidence about the CI build. wasm-pack pinned to v0.15.0 and added to the devShell -- note the wasm CI job only ever ran cargo check, so NOTHING had previously required the one tool that turns a passing check into a loadable page.\n\nMEASURED: build.sh succeeds end to end -- 346 KB wasm, 120 KB gzipped, 388 KB assembled site, every reference resolving inside it, all asset paths relative so the /improvise/ sub-path works. actionlint clean. THE WORKFLOW HAS NEVER RUN: unproven until the first main push.\n\nNOT PATH-FILTERED, deliberately. The bundle is improvise-wasm-client + protocol + core + formula, so a filter meaning 'skip when the bundle cannot have changed' would name most of the workspace, and getting it wrong leaves a STALE SITE WITH NOTHING SAYING IT IS STALE. The over-build is the cheaper error.\n\nWHAT REMAINS HERE, and one part was not predicted by this ticket. The deployed page does not work -- app.js holds no model, so it needs a server -- and the ?ws= escape hatch ALSO fails, because Pages is https and a ws:// address from an https origin is blocked as MIXED CONTENT. There is currently NO configuration in which the published URL does something useful. That is stated in STATIC HTML (#backend-notice) rather than by app.js, because a page that cannot reach a server cannot be trusted to explain why. Close this when improvise-djm makes the page self-hosting.\n\nAC 4 (bundled demo model) is MOVED TO improvise-bck: nothing in the browser reads a .improv until improvise-9x6 gives the worker an init-from-bytes entry, so the file would have no consumer.\n\nHUMAN ACTION: Pages is not enabled (the API 404s). configure-pages@v5 enablement:true should create it on the first main push; if the token may not, Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS (not the branch/folder source improvise-3gy currently names).","status":"closed","priority":1,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:36Z","created_by":"spot","updated_at":"2026-09-20T04:20:53Z","closed_at":"2026-09-20T04:20:53Z","close_reason":"Done -- the page now works from its own URL, which is what this ticket was waiting on.\n\nThe DELIVERY half landed earlier (pages-build/pages-deploy appended to the existing ci.yml, so the deploy is a needs: edge on test+wasm rather than a workflow_run dance; build.sh runs unchanged). What was missing was that the deployed page could not DO anything: app.js held no model and the ?ws= escape hatch was blocked as mixed content. improvise-djm and improvise-9x6 closed that, and the static backend notice is gone.\n\nVERIFIED BY THE USER, 2026-09-20, Safari 27: https://fiddlerwoaroof.github.io/improvise/ renders the Corner Coffee demo from the in-page worker -- Product rows, Revenue/Cost/Profit columns, Quarter on page, and the formula column evaluated.\n\nONE DEFECT FOUND AND FIXED HERE, worth remembering because it cost two round trips: Pages serves everything max-age=600 and the wasm-pack filenames carry NO CONTENT HASH, so for ten minutes after a deploy a returning visitor runs the PREVIOUS build. That made a shipped fix look like it had not shipped, twice -- the deployed artifact was verified to be the right commit while the browser was still running the old one. The assemble step now stamps ?v=\u003csha\u003e onto BOTH the glue and the .wasm, because a fresh glue does not pull a fresh binary: the glue locates it with new URL('..._bg.wasm', import.meta.url) and a URL built against a base does not inherit the base's query.","dependencies":[{"issue_id":"improvise-d31","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-djm","title":"Standalone main-thread bundle: wasm-client + worker transport","description":"Main-thread wasm entry point for the static-web standalone deployment. Hosts the existing thin-client wasm (improvise-wasm-client) inside the browser tab, registers a worker (dedicated or service) that hosts the worker-server bundle, and wires a MessageChannel between them carrying Command messages in exactly the same shape the websocket carries in the thin-client-over-network deployment. The main-thread code does not know whether its peer is a remote server or a local worker — same protocol, different transport. Result: standalone deployment is just the thin-client architecture with a local transport.","design":"Two wasm bundles loaded by different parts of the browser runtime. (1) Main-thread bundle = improvise-wasm-client, unchanged from the network deployment. ViewState, reduce_view, keymap, DOM renderer. Small (~300 KB). (2) Worker bundle = worker-server, a separate issue. Full App + command pipeline + projection layer + VFS persistence. Heavy (~1-3 MB). Transport: MessageChannel with serde-json (or bincode) over postMessage. Main-thread Rust code is already transport-agnostic because on_message accepts serialized commands regardless of source and outgoing commands are returned to JS — the transport abstraction lives at the JS boundary. This issue is the main-thread entry point: JS bootstrap that instantiates wasm-client, spawns the worker, constructs the MessageChannel, routes messages in both directions. Worker type: start with Dedicated Worker (new Worker('worker.js')) for cleaner lifecycles and confirmed OPFS access; explore Service Worker later for PWA/offline-install scenarios.","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 3 MB compressed (stretch: under 2 MB). (3) Initializes with a bundled demo .improv file on first load. (4) All core interactions work: cursor move, cell edit, formula entry, save, reload. (5) Can be driven end-to-end from a small JS harness without any server process running.","notes":"Depends on improvise-gsw (thin-client wasm must exist — reused literally), the new worker-server issue, improvise-cr3 (DOM renderer). Shares code with the thin-client epic to the maximum extent possible: the main-thread wasm bundle is the same artifact; only the JS bootstrap differs in how it instantiates the transport.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:23Z","created_by":"spot","updated_at":"2026-09-20T02:29:49Z","closed_at":"2026-09-20T02:29:49Z","close_reason":"Done. web/app.js spawns the worker: new Worker('./worker.js?doc=\u0026seed=', { type: 'module' }).\n\nTWO CORRECTIONS TO THE TICKET'S OWN DESCRIPTION, both dates rather than decisions:\n * NO MessageChannel is needed or wanted. worker.js uses self.onmessage/self.postMessage -- a dedicated worker's OWN implicit port -- so worker.postMessage / worker.onmessage is the pair.\n * type: 'module' is MANDATORY, since worker.js imports the bundle. A classic worker fails to parse it, and that failure does reach the error listener.\n\nTHE WEBSOCKET PATH IS KEPT, worker is the default: ?ws= selects the socket, its absence spawns the worker, and THE PRESENCE OF THE PARAMETER IS THE WHOLE SELECTION MECHANISM -- a second knob (?transport=) would name the same fact twice. Kept because improvise-ws-server is the only way a TUI-hosted document is shared, and deleting the page's path to it would leave a working server nothing could reach. The cost is not 'two things to keep working': the transports differ only in WHERE STRINGS COME FROM. Handshake, on_frame, repaint, typed_cell and keydown are all above the seam, so the difference is confined to two ~15-line factories returning { send, ready }. That is exactly what improvise-xks6 bought -- one envelope pair, two deployments.\n\n#status now composes TWO facts, the transport's and the document's, because a single source let a repaint silently erase a connection error.\n\nMEASURED: client 345 KB / 123 KB gzipped, worker 1.33 MB / 498 KB gzipped, total site 1.8 MB / ~620 KB gzipped -- comfortably inside the 3 MB budget. build.sh end to end under nix develop; the CI assemble step replayed locally with every relative reference checked to resolve. Every JS call confirmed against the generated .d.ts with the right arity.\n\nNOT VERIFIED, and this is the whole remaining risk: NOBODY HAS OPENED THE PAGE. No browser, no headless DOM. Every line of app.js and worker.js is read, not run -- the module-worker spawn, OPFS, ResizeObserver arithmetic, click-to-edit, keydown, the CSS. The Pages workflow has also never run.\n\nAC 4 is partly blocked: cell edit, formula entry and reload work through Commands; keyboard-driven interaction needs improvise-bpq2, and today NO KEY PRODUCES A VISIBLE CHANGE -- Host::handle answers ClientFrame::Key with an empty vec, and keys.rs resolves locally only in minibuffer modes you can only enter via an upstream key. The listener is wired and honest about doing nothing.","dependencies":[{"issue_id":"improvise-djm","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:14Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:40:15Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-djm","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:40:13Z","created_by":"spot","metadata":"{}"}],"dependency_count":3,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","acceptance_criteria":"Closes when improvise-bck does. bck is now mostly demonstrated by harness/ in CI rather than by hand; its one genuinely unmet criterion is 'add a formula', which is a missing FEATURE and is filed as improvise-fdt6. So this epic's remaining content is: fdt6, plus whatever bck's annotation lists as not-demonstrated (Safari via improvise-xf1, two tabs, bindings.rs as a code path). Do not close it on the strength of the other children being closed — check bck's own list.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-bck, which is itself held\nby two open children. This epic's own criteria say not to close it on the\nstrength of the other children being closed but to check bck's list, so that\nis what was done — see bck's verification annotation for the criterion-by-\ncriterion result.\n\nHolding it: improvise-fdt6 (a browser cannot commit a formula at all, so bck's\ntest-plan step 5 has no implementation) and improvise-zkkw, filed this pass\n(bck criterion (1) says \"Chrome and Firefox\"; shell.spec.js runs on Chromium\nonly, which bck's earlier annotation did not record). Two of bck's five\ncriteria are now met with measured evidence rather than assertion: the\ndeployed page reaches an editable grid in 278-1172 ms from a cold browser, and\nthe two wasm binaries are 699 KB gzipped off the live site against a 3 MB\nbudget.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-09-20T18:05:06Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-bck","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tm6","title":"Epic: Standalone static-web deployment via wasm + VFS storage","description":"Enable improvise to run entirely in the browser with no backend, suitable for GitHub Pages or any static host. Full App compiles to wasm; ratatui is stripped (automatically, by not depending on improvise-tui); persistence goes through a VFS/Storage abstraction backed by OPFS (or IndexedDB fallback) in the browser and by PhysicalFS on native. Shares the DOM renderer and Command protocol with the thin-client epic (improvise-6jk) — same code, different transport (in-process channel instead of websocket).","design":"Three deployment modes share code: (1) native TUI = today, (2) thin client over websocket = epic 6jk, (3) static web standalone = this epic. Mode 3 bundles the full App (improvise-core + improvise-formula + improvise-command + improvise-io's persistence half) into wasm. Same Command protocol and DOM renderer as mode 2; transport is an in-process command bus. Persistence abstracted behind a Storage trait (or the vfs crate) with PhysicalFS backing for native and OPFS backing for wasm. This makes sqlite/parquet formats a later orthogonal win — VFS handles bytes→medium, new format modules handle data→bytes. DOM renderer still reads ViewState + render cache even in standalone mode; an in-process projection layer updates the cache as App state changes. Ratatui falls out for free once crate-split epic completes: wasm build simply doesn't depend on improvise-tui.","acceptance_criteria":"Closes when improvise-bck does. bck is now mostly demonstrated by harness/ in CI rather than by hand; its one genuinely unmet criterion is 'add a formula', which is a missing FEATURE and is filed as improvise-fdt6. So this epic's remaining content is: fdt6, plus whatever bck's annotation lists as not-demonstrated (Safari via improvise-xf1, two tabs, bindings.rs as a code path). Do not close it on the strength of the other children being closed — check bck's own list.","notes":"Depends on crate-split epic's steps 3 (improvise-io) and 5 (improvise-command) being complete — both become critical-path under this epic. Shares protocol crate (improvise-cqi) and DOM renderer (improvise-cr3) with thin-client epic. Bundle size estimate: 1-3 MB compressed; biggest contributors are pest (formula parser) and chrono. Optimization (wee_alloc, wasm-opt, pest error trimming) is follow-on.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-bck, which is itself held\nby two open children. This epic's own criteria say not to close it on the\nstrength of the other children being closed but to check bck's list, so that\nis what was done — see bck's verification annotation for the criterion-by-\ncriterion result.\n\nHolding it: improvise-fdt6 (a browser cannot commit a formula at all, so bck's\ntest-plan step 5 has no implementation) and improvise-zkkw, filed this pass\n(bck criterion (1) says \"Chrome and Firefox\"; shell.spec.js runs on Chromium\nonly, which bck's earlier annotation did not record). Two of bck's five\ncriteria are now met with measured evidence rather than assertion: the\ndeployed page reaches an editable grid in 278-1172 ms from a cold browser, and\nthe two wasm binaries are 699 KB gzipped off the live site against a 3 MB\nbudget.","status":"open","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:45Z","created_by":"spot","updated_at":"2026-09-20T18:05:06Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-tm6","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:28Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-9x6","type":"blocks","created_at":"2026-04-14T00:40:31Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-d31","type":"blocks","created_at":"2026-04-14T00:40:33Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-djm","type":"blocks","created_at":"2026-04-14T00:40:32Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-i34","type":"blocks","created_at":"2026-04-14T00:40:30Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-tm6","depends_on_id":"improvise-ywd","type":"blocks","created_at":"2026-04-14T00:40:29Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fpo","title":"Dynamic _Measure: formula targets auto-included","description":"_Measure should dynamically include all formula targets without add_formula manually adding items. Formula refs should resolve against Model::formulas as fallback. CommitFormula should not need to pick a target category for _Measure.","status":"closed","priority":1,"issue_type":"bug","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T10:14:14Z","created_by":"spot","updated_at":"2026-04-09T20:52:01Z","closed_at":"2026-04-09T20:52:01Z","close_reason":"Dynamic _Measure, optional target_category, atomic formula eval on load, skip virtual category persistence","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h4r","title":"Replace ad-hoc parser with pest grammar","description":"The pest grammar at src/persistence/improv.pest defines the .improv format spec. Replace the hand-written line-scanner in parse_md with a pest-derived parser that walks the grammar's parse tree. This eliminates drift between spec and implementation.","status":"closed","priority":1,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-09T09:03:53Z","created_by":"spot","updated_at":"2026-04-09T09:32:38Z","closed_at":"2026-04-09T09:32:38Z","close_reason":"Replaced ad-hoc line scanner with pest-derived parser. Grammar at improv.pest is now the single source of truth for both parsing and test generation. Grammar-walking generator uses pest_meta to read the grammar AST at test time and produce random valid files as proptest entropy.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5xd","title":"Rename Measure to _Measure (virtual category)","description":"Measure should be a virtual category (_Measure) like _Index and _Dim. This exempts it from the 12-category limit and follows naming conventions. Requires updating: import wizard, evaluate_aggregated hardcoded reference, persistence parser, demo.improv, and CategoryKind.","status":"closed","priority":1,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T08:52:11Z","created_by":"Edward Langley","updated_at":"2026-04-09T09:40:23Z","closed_at":"2026-04-09T09:40:23Z","close_reason":"Measure renamed to _Measure (VirtualMeasure kind). Fixed-point formula evaluation implemented via recompute_formulas cache. Formulas now resolve correctly with hidden dimensions.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -89,46 +102,111 @@ {"_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-3h9w","title":"A captured editor's Enter is refused by the cursor's cell: refused_here runs before the capture is consulted (#274 after merging #286)","description":"Codex review of PR #274 at 8c28360f (P2), confirmed by the adversarial reviewer. The merge of #286 into #274 introduced it; it is a regression against 8d9de94e.\n\n`step()` answers `declined(name).or_else(|| refused_here(state, name))` BEFORE `committed()`/`commit()`. For the two commit keys, `refused_here` judges the **cursor's** cell (`declined_at` or `unprojected`) and ignores `state.opened`. So an editor that captured a still-valid stored cell is refused whenever a relayed reframe leaves the cursor on an aggregate, a derived cell, or an unprojected cell.\n\n**Probe** (real Host, `host()` fixture, plus a formula `Profit = Revenue - Cost`):\n1. A presses `l i 4 2`, so the capture is `Target(North, Value{Cost})`.\n2. B sets Region's axis to None. The relays are delivered, and the reset puts A's cursor on (0,0), which is now `Aggregate`.\n3. A presses Enter.\n- At **8c28360f**: no frame. The status is \"Cannot write to an aggregate…\", the buffer is still 42, and North/Cost is None. Every retry does the same, so the only way out is Esc, which loses the text.\n- At **8d9de94e**: `Accepted`, and North/Cost = 42.\n\nThis is 8xq0's TOCTOU returned as a false refusal, the shape of Codex's first P2 on this PR (dispatch from the cursor). The author's `a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on` does not see it, because an empty grid is exempt from `unprojected` (51943d1b) and has no aggregate.\n\n**Fix direction:** apply the `declined_at`/`unprojected` guard for commit keys only when `state.opened` is None (an uncaptured editor), or derive it from `opened.target()`. Add the regression: a valid capture, with the cursor's cell changed to an aggregate or derived cell.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T02:51:47Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T03:03:50Z","started_at":"2026-09-24T03:03:50Z","lease_expires_at":"2026-09-24T03:08:50Z","heartbeat_at":"2026-09-24T03:03:50Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-3h9w","depends_on_id":"improvise-7mro","type":"related","created_at":"2026-09-24T02:51:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-3h9w","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T02:51:54Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2zw9","title":"Browser: keys typed before a cell is projected diverge from the TUI's value (held i 7 Enter writes 7 where the TUI writes 47)","description":"After improvise-7mro (#286), a key resolved on a cell outside the held window never names the wrong record: i opens an editor awaiting its seed, and a commit/x/yy/p waits out loud. But with the host's replies held, the user types into an empty editor and the retry after the answer writes what they typed (7), where the TUI, which showed the seed, writes 47. Exact agreement needs the client to DEFER keys resolved on an unprojected cell until the projection arrives, which means on_frame handing queued frames back to web/app.js (a page-surface change) - or rejecting that as UX and documenting it. Pinned today by keys_sent_before_a_scroll_is_answered_agree_with_the_tui's Replies::Held arm, which compares written cells, not values.","notes":"Raised to P2 per review of #286: typed-ahead text accumulates across out-loud waits (i 5 Tab 3 Tab 8 Tab -\u003e next Tab writes 38 where TUI writes 8). Same design question as improvise-n4i9 (user decision pending): defer keys until the cell arrives.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:35:44Z","created_by":"agent-7mro","updated_at":"2026-09-24T02:46:28Z","labels":["browser","pr-286"],"comments":[{"id":"dcfa7281-9233-574a-9d68-d1490c1c98f5","issue_id":"improvise-2zw9","author":"agent-review-277","text":"From the adversarial review of #286 at 6b604a9e. This is a second symptom with the same fix (deferring keys), and I think it is worth P2 rather than P3.\n\nThe case is type-ahead data entry across a row wider than the window, with replies held (any socket latency). The fixture is `_Measure` rows Revenue/Profit/Cost and Months Jan/Feb, window 2 rows × 1 col, cursor on Revenue/Jan. Keys `i 5 Tab 3 Tab 8 Tab`:\n- TUI: Jan/Revenue = 105, Feb/Revenue = 8.\n- Browser at 6b604a9e: Jan/Revenue = 105. The second Tab is refused (\"waiting for the host to show this cell\"), and the refusal keeps the typed text. So the next digit appends, and the editor ends open with buffer **\"38\"**. The next Tab after the answer writes 38 to Feb.\n\nA refusal that keeps the buffer turns fast typing into concatenated values from two intended cells. At 6942e53e the same keys lost the text silently, and neither head writes a wrong record. The out-loud refusal is better than silence, but under latency it happens on every Tab at the window's edge.\n","created_at":"2026-09-24T02:45:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-dndu","title":"PR #245's merge of #228 dropped the check-harness-lock --allow-network CI step; CI now runs only the checker's self-test","description":"## Summary\nFound reviewing PR #245 at head f41a946b, the merge of #228 `1bec7c0f` into the zs2r branch.\n\nThe merge message says \"The --allow-network step is unchanged\". It is gone. `git diff 1bec7c0f f41a946b -- .github/workflows/ci.yml` is **empty**. The `test` job step that #245 added after \"Every workspace root is formatted\" is absent at f41a946b:\n\n```\n # A manifest-only change to a crate the harness reaches by path stales\n # its own lock, which only the slow `build` job saw (improvise-zs2r).\n - name: The harness lock matches the manifests it resolves against\n run: ./scripts/check-harness-lock.sh --allow-network\n```\n\nThat step was at lines 147-150 of 23b8dbf5. `grep harness-lock` on f41a946b's ci.yml finds nothing.\n\n## Effect\nCI now runs only `check-harness-lock-test.sh`, the checker's self-test, through the `kb` row of `ci_suites`. It never runs the lock check itself against the real `harness/opfs-contract/Cargo.lock`, which was the whole point of zs2r.\n\nNo gate notices:\n- `check-ci-graph.py` exits 0 (\"all 10 suites run\"). It counts suites, not this step, and the step does not mention `checkers.sh`.\n- `context/code/tooling.md` at f41a946b still says \"CI runs with `--allow-network`\" (around lines 952 and 977), so the doc now misstates CI.\n\n## Fix\nRestore the four lines above in the `test` job, after the fmt-workspaces step. The `kb` row addition stays. Then check `git diff 1bec7c0f \u003chead\u003e -- .github/workflows/ci.yml` shows exactly that step.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:15:10Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T02:15:10Z","labels":["adversarial-review","pr-245"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xpzh","title":"check-ci-graph splits lines on \\n only; YAML also breaks at U+0085/2028/2029, so a job if: after one is invisible","description":"## Summary\nFound in adversarial delta review of PR #228 at head 7ae871cb (the 0wn3 value allowlist, `9dc8a643..7ae871cb`).\n\n`ci_steps` splits the workflow on `\\n` only. Text-mode `open()` also folds `\\r` and `\\r\\n`, so those are safe (verified). But YAML 1.1 parsers also break lines at NEL (U+0085), LS (U+2028) and PS (U+2029). A line such as ` runs-on: ubuntu-latest\u003cU+2028\u003e if: false` is therefore two lines to YAML and one line to the gate. To the gate it is key `runs-on`, whose value is a plain scalar starting with `u`, which the allowlist accepts. The job's `if:` never reaches `if_jobs`.\n\n## Reproduce (real ci.yml)\nAdd a job `gate` with that line (`runs-on`, then U+0085/2028/2029, then `if: false`), plus `steps: [- run: echo]`. Give kb `needs: gate`, and add `gate` to pages-deploy's `needs`.\n\n- go-yaml (yq): `.jobs.gate | has(\"if\") == true`, `.if == false`. Same for U+2028.\n- PyYAML 6.0.3 and Ruby Psych 5.2.2 (libyaml): `gate.if == false` for all three characters.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- Controls:\n - a bare CR (`\\r`) at the same spot: exit 1, caught, because text-mode `open` folds it;\n - CRLF, and a whole-file CRLF: exit 1;\n - plain LF: exit 1.\n\nThe same mechanism hides kb's own `needs:` behind its `name:` line. GitHub's workflow parser is built on YamlDotNet, which also treats U+0085/2028/2029 as breaks. I have not verified that against GitHub itself.\n\n## Fix\nRefuse any line containing U+0085, U+2028 or U+2029. \"Refuse, don't parse\": these characters have no business in a workflow. Splitting on them would also work, but it is a second definition of a line. Add one case per character, and a CR control.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:50:56Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T01:54:11Z","started_at":"2026-09-24T01:54:11Z","lease_expires_at":"2026-09-24T01:59:11Z","heartbeat_at":"2026-09-24T01:54:11Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0dbk","title":"#266 and #279 each add ClientState::notify: their merge does not compile, and #266's rebase() skips #279's settle_status","description":"Found reviewing #266 (57b713cf, improvise-xd3q/n0pb) against #279 (9eb83538, improvise-1x9h). I materialised the merge of the two heads.\n\n1. **The merge does not compile.** Git merges `client.rs` cleanly, but each side adds its own `ClientState::notify`, so the result has E0592 (duplicate definitions). The only textual conflict is in `context/test-evidence/clients.md`. A clean merge of the Rust files is exactly what makes this easy to miss.\n - #266's version queues the sentence through `fold_outgoing` (at the tail of `in_flight`).\n - #279's version writes `view.status_msg` directly and calls `settle_status`.\n\n2. **Keep #266's `notify`; #279 should adapt.** #279's direct write is the shape n0pb removed: a rebase erases it. #266's version already records an owner: `fold_outgoing` → `apply` → `settle_status`. The owner lives on `ClientState`, not per slice, so settling the queued entry later does not lose it.\n - With #279's `notify` deleted, the merged tree passes the whole workspace, including #279's `a_rejection_that_arrives_in_an_open_editor_stands_beside_it`.\n\n3. **After the merge, `rebase()` must also call `settle_status()`.** #266's `rebase()` calls `replay`, `settle_mode` and `settle_editor`, but not #279's `settle_status`. So a rebase that moves the view out of the editor a message was written in does not clear `status_owner`. Returning to an equal editor then makes the message stand again, which contradicts #279's rule that \"an equal editor opened later is another opening\".\n - Probe, in the merged tree:\n 1. `SetSelected(1,1)` → `ChangeMode(editing)` → `SetStatus`; the message stands.\n 2. Send an awaited frame.\n 3. `fold(ResetCursor)`, which rebases.\n 4. `fold_outgoing([SetSelected(1,1)])`.\n - Result: `status_stands()` is true, and should be false.\n - Adding `self.settle_status();` to `rebase()` makes the probe pass, and the whole workspace stays green.\n\nWhichever of the two PRs lands second owns both repairs. #279 is the one that has to adapt: it drops its `notify` and adds the `settle_status` call to `rebase`.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:37:59Z","created_by":"agent-review-246b","updated_at":"2026-09-24T01:37:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-y5eb","title":"Browser settle_declined_editor reads the pre-scroll cache: a Down-wrap from a scrolled window closes an editor the TUI opens, naming a formula not under the cursor (#282)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. PR #282 introduces this; PR #277 alone (aab62a36) does not have it.\n\n`ClientState::settle_declined_editor` runs after every fold, including the fold of the client's OWN outgoing frame. The frame for an advance is `scrolled_to` (SetSelected, SetRowOffset/SetColOffset) and then the open (SetBuffer, SetOpenedEditor, ChangeMode). By the time ChangeMode(Editing) is folded, the view's offsets are the NEW ones, but the GridCache still holds the OLD window, because the host has not answered yet. `declined_at` -\u003e `cell_at` subtracts the new offsets and indexes the old cache, so it reads a different cell from the one under the cursor.\n\nA one-step scroll happens to read the cell just committed, which is editable, so it is harmless. A Down that WRAPS from the bottom row to (0, col+1) does not. It jumps the row offset back to 0, and cache row 0 is then the old window's top row.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed). `_Measure` on rows [Revenue, Profit, Cost], where Profit is an explicit item AND `Profit = Revenue - Cost`. Month [Jan, Feb] on columns. Client window (2, 6), placed at (2,0) with row_offset 1, so the window shows Profit and Cost. Keys `i`, `Enter`:\n- TUI: lands on (0,1) Revenue/Feb in Editing.\n- Client, straight after fold_outgoing(Enter): (0,1), mode Normal, status \"Profit is computed by a formula — edit the formula, not the cell\". Profit is not the cell under the cursor.\n- Delivering the host's reply does not repair it, because the settle only ever closes an editor.\n\nThis also holds on a materialised merge of #282 with #272 (which includes #227). There the awaiting-seed editor is untouched too, so the settle closes it the same way.\n\nA fix direction, not a prescription: settle only against a cache framed for the view's current offsets (the row/col frames' `first` equal to the cursor offsets), or skip while this client's own scroll is unanswered.\n\nAC: a lockstep test with a Down-wrap from the bottom of a scrolled window onto an editable cell whose stale-window counterpart is declined. The client and the TUI must agree on the mode. A rival that removes the guard must fail it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:00Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:00Z","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0wn3","title":"check-ci-graph: a multi-line flow value with an open string is not refused, and a key-shaped closer makes its content read as a counted run:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 9dc8a643 (the 6zf1 fix, `588094b7..9dc8a643`). That fix leaves \"flow values spanning lines\" unrefused, on the argument that they fail closed. They do not, when the flow value's closing line is spelled like a step key.\n\nA step key whose value opens a flow collection (`[a, \"b`, or `{a: \"b`) with a quoted string that stays open is a plain inline value to the gate. `_opens_quote` answers only for a value that *starts* with a quote. The owner rule then owns only lines deeper than the step's key column, 8. So the lines *inside* the flow value at indent 8 are read as step keys, including an exact `run: …checkers.sh --ci kb`.\n\n## Reproduce (real ci.yml)\nReplace kb's last step with:\n```\n - name: [a, \"b\n run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb\n c: x\"]\n```\n- go-yaml (yq), PyYAML 6.0.3 and Ruby Psych 5.2.2 all read the step as `{\"name\": [\"a\", \"b run: nix shell … --ci kb c: x\"]}`, a step with **no `run:`**.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb's checkers step never runs.\n- The same with a single-quoted string: exit 0.\n- The same as a flow map, `{a: \"b` … `c: x\"}`: exit 0.\n- Controls:\n - a closer that is not key-shaped (`c\"]`): exit 1, refused as unreadable. This is the case the fail-closed argument covered.\n - a closer whose value is quoted (`c: \"x\"]`): exit 1. It is also invalid YAML.\n - the no-quote form `[a,` … `c: x]`: all three parsers reject it.\n\n## Fix\nIn keeping with \"refuse, don't parse\": refuse any inline value that begins a flow collection (`[` or `{`) and does not close it on the same line. A bracket count outside quotes is enough, or simply require the value to end in the matching bracket. The one-line `needs: [a, b]` form stays readable. Add one case each for `[`, for `{`, and for the single-quoted form.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:25:52Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T01:32:11Z","started_at":"2026-09-24T01:32:11Z","lease_expires_at":"2026-09-24T01:37:11Z","heartbeat_at":"2026-09-24T01:32:11Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4k1j","title":"Browser H under nested row categories hides a group name: hidden_row_item's fold test is the nearest group, not the row's fold","description":"Found reviewing PR #275 (improvise-fo0w) at cdefeeb9. It is the client-side return of improvise-yk2s, which was fixed on the host. resolve.rs::hidden_row_item refuses on a fold only when frame.group_of(idx) is collapsed. But group_of is the NEAREST group header. With two row categories, where the outer one (Region) has a collapsed group Coast and the inner one (Product) has its own group Kit, the fold rows (Coast, Gadget) and (Coast, Gizmo) have Kit as their nearest header. Kit is expanded, so the client does not refuse. It composes HideItem{Region, item: \"Coast\"}, a group name, and the twin host accepts it and writes 'hidden: Region/Coast'. The host (HideSelectedRowItem) asks layout.row_folds_a_group, which reads the DataItem's own collapsed flag, and refuses with FOLD_REFUSAL. Reproduced by sweeping H over every row of four grouped fixtures in lockstep: 2 of 30 rows diverge. The divergence appears with a 2-row window at an offset and with a whole-grid window. It survives a merge with #263 (the e265/uv9e stack). hiding_on_a_fold_refuses_as_the_host_does uses one row category, so its fixture lacks the structure. Fix direction: the frame must carry the fold fact itself, as the per-item collapsed flag the layout already has, rather than the client inferring it from the nearest header.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:15:13Z","created_by":"agent-review-264","updated_at":"2026-09-24T01:15:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6zf1","title":"check-ci-graph: the quote tracker closes a multi-line string on an escaped \\\" or doubled '', so a later quote reopens it over a real if:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 588094b7 (the j49r fix, `5738e8e0..588094b7`). The bug predates that delta: it also exits 0 at 5738e8e0, and it has been present since the quote tracker landed (st8q).\n\nWhile a multi-line quoted value is open, `ci_steps` closes it on **any** line that contains the quote character (`if quote in raw`). YAML does not close it on an escaped quote (`\\\"` inside double quotes) or on a doubled quote (`''` inside single quotes). So the tracker closes early, and the rest of the string is read as structure. A later content line such as `b: \"` then looks like a key that opens a new quote, and that phantom string swallows the job's real keys until the next quote character.\n\n## Reproduce (real ci.yml)\nInsert before `kb:`, give kb `needs: gate`, and add `gate` to pages-deploy's `needs`:\n```\n gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n if: false\n steps:\n - run: echo \"z\"\n```\n- yq-go: `gate.name == 'a it\" is b: '`, `gate.if == false`, `kb.needs == \"gate\"`.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- The single-quoted form also exits 0: `'a` / `it''s` / `b: '`.\n- The same with the reopening line at indent 4: exits 0.\n- Control, a plain two-line quoted name: exit 1, caught as conditional.\n\n## Fix\nClose the tracker only on an unescaped closing quote: ignore `\\\\.` for a double-quoted value, and `''` for a single-quoted one. This mirrors `_opens_quote`. With exactly that change, both plants exit 1 and all 47 self-test cases still pass, so no existing case covers the hole. Add one case per quote style. Alternatively, refuse any multi-line quoted value, which is in keeping with \"refuse, don't parse\".","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:10:40Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T01:13:33Z","started_at":"2026-09-24T01:13:33Z","lease_expires_at":"2026-09-24T01:18:33Z","heartbeat_at":"2026-09-24T01:13:33Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8phw","title":"A keyboard value-edit retry after a crossing LayoutMoved writes the cell now under the cursor (8xq0's crossing twin, 9ww8 for values)","description":"Found reviewing PR #274 at head 7bf8debd (improvise-8xq0's fix). This is the value-cell twin of improvise-9ww8.\n\n#274 captures the opened cell once the host has answered. The 8xq0 test delivers B's HideItem to A BEFORE A presses Enter, so A's capture from when the editor opened is used, and it is correct. But when A's Enter crosses B's HideItem in flight (ordinary latency):\n- A's frame is refused `LayoutMoved`, because hiding a row moves the layout tier.\n- `refused()` replays the settled slice. The editor is back at (0,1) with buffer \"42\", and `settle_opened` recaptures what is now under the cursor, which is **South**/Cost at its current version.\n- The retry Enter is accepted, so 42, typed for North/Cost, lands on South/Cost.\n\nProbe (real Host, `host()` fixture, the keys `l i 4 2`, B's relays held until A's Enter is answered):\n- first answer `Rejected(LayoutMoved)`;\n- after the refusal, `opened.record` is South's hash (it was North's before);\n- retry `Accepted`;\n- cells after: North/Cost None, **South/Cost 42**.\n\nThe force control holds: if B writes North/Revenue instead, the refusal is `RecordMoved(north)` and the retry writes North/Cost (it is a force on the same cell).\n\nFix direction: #251's `Opened::Retry(r)` rule (`ff8ea239`) already solves this for renames. Its `renamed()` only matches `RenameRecordCoordinate`, and its comparison is on the record only. When #274 and #251 are merged:\n- `renamed()` should also match `SetCell`/`ClearCell`, as the record plus the measure;\n- `Retry` should compare the whole `OpenedTarget` (record + measure, or record + category) before it accepts the recapture.\n\nA test must hold B's relays until after A's Enter.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T01:04:42Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T01:14:45Z","started_at":"2026-09-24T01:14:45Z","lease_expires_at":"2026-09-24T01:19:45Z","heartbeat_at":"2026-09-24T01:14:45Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-8phw","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T01:04:52Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-8phw","depends_on_id":"improvise-9ww8","type":"related","created_at":"2026-09-24T01:04:53Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-eahe","title":"shipped_documents_load test binary exceeds the 2s tripwire: one child process per env setting","description":"Measured on #276 (M4 Max, load ~120): tests/shipped_documents_load.rs takes 2.6-3.7s, over design-principles §7's 2s per-binary tripwire. Cause: child-process reruns added by 53zz/vwjj/pr3i/a0i3/2iws — a0i3 alone spawns ten (five settings x two tests). Fix: set all the hostile settings in ONE child per test (the rivals already show each setting breaks the walk on its own, so a combined child still discriminates per-setting via rivals), or otherwise reduce child count. Measure before/after naming the machine; report the ratio to the run's median binary.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:49Z","created_by":"orchestrator","updated_at":"2026-09-24T01:03:22Z","started_at":"2026-09-24T01:03:22Z","lease_expires_at":"2026-09-24T01:08:22Z","heartbeat_at":"2026-09-24T01:03:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n0pb","title":"A relay heard while a frame is in flight erases the rejection sentence (PR #266 rebases view from settled, and the sentence is only in view)","description":"Found in adversarial review of PR #266 (improvise-xd3q) at e4b5ce90. The PR introduces it: at its base, 790a33ba, the same probe passes.\n\n`Client::fold_frame` (crates/improvise-wasm-client/src/socket.rs) handles `ServerFrame::Rejected` by writing the rejection sentence directly into `state.view.status_msg`, after `refused` rolls back. That sentence lives only in `view`: it is never in `settled` and never in `in_flight`. #266 makes `ClientState::fold` rebuild `view` from `settled` plus `in_flight` whenever a relay queues behind an in-flight frame. So the next such relay erases the sentence, and the user loses the one notice that their edit was not applied. `last_rejection` survives, but the sentence is gone.\n\nReproduced through the page's own entry point, `Client::fold_frame`:\n1. Two awaited frames go out through `fold_outgoing`, each a `Command::Model`.\n2. The first is refused with `Rejected(LayoutMoved)`, and the sentence is set.\n3. A relay, `Commands([ResetCursor])`, arrives while the second is still in flight.\n\nAt e4b5ce90 the result is `status_msg == \"\"`. At 790a33ba it is the sentence. This is reachable whenever the refused frame is followed by an accepted frame that relays: a pruning commit's reset and echo, or another client's reset. (A second refusal would write a fresh sentence, which is why it has to be an accepted frame.)\n\nFix options:\n- Write the sentence into `settled` as well as `view`.\n- Or keep it outside `ViewState` and have `rebase` preserve it.\n\nThe same hazard applies to any other write into `view` that a later rebase does not reproduce. `settle_editor`'s seed is re-derived by `rebase`, so it is safe. This sentence is the one case I found.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:55:16Z","created_by":"agent-review-246b","updated_at":"2026-09-24T01:24:26Z","started_at":"2026-09-24T01:24:26Z","lease_expires_at":"2026-09-24T01:29:26Z","heartbeat_at":"2026-09-24T01:24:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j49r","title":"check-ci-graph: a job header with a trailing comment is accepted but never opened, so its if: and the job itself vanish from both rules","description":"## Summary\nFound in adversarial delta review of PR #228 at head 5738e8e0 (the rjaa fix, `3eafd4de..5738e8e0`).\n\nA job header with a trailing YAML comment, such as ` gate: # skipped on purpose`, is valid YAML. GitHub reads it as job `gate`. The gate disagrees with itself about the line:\n- `_readable` is handed the comment-stripped `line`, so it accepts the header;\n- `_JOB.match(raw)` in both `ci_steps` and `read_graph` runs on the raw line with the comment, so it does not match.\n\nThe job is therefore never opened. Its keys, `if: false` included, are attributed to the previous job, and `read_graph` never lists it at all.\n\n## Reproduce (real ci.yml)\nInsert, before ` wasm:` so that the preceding job is `test`, which is not counted:\n```\n gate: # skipped on purpose\n if: false\n runs-on: ubuntu-latest\n steps:\n - run: echo\n```\nGive kb `needs: gate`. Leave pages-deploy unchanged.\n\n- yq-go: `.jobs.gate.if == false`, `.jobs.kb.needs == \"gate\"`, and 8 jobs besides pages-deploy.\n- `check-ci-graph.py` **exits 0** with \"pages-deploy needs all 7 other jobs; all 9 checkers.sh suites run in CI\".\n\nSo both rules fail open at once. On a PR, kb is skipped behind gate. And pages-deploy does not need gate (x7cq's own rule), which nothing reports.\n\n## Controls\n- A trailing tab or trailing spaces on the header: refused (exit 1), because `_readable` sees them too.\n- A comment on the `if:` line: caught.\n- A `mid: # c` header in a two-hop chain: exit 1, but only by accident. Every attempt I made at a clean version was caught by some other rule.\n\n## Fix\nMatch the job header on the same comment-stripped `line` everywhere (`ci_steps` and `read_graph`), or refuse a comment on a job header. Add a self-test case and a `read_graph` graph case.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:47:23Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T00:53:00Z","started_at":"2026-09-24T00:53:00Z","lease_expires_at":"2026-09-24T00:58:00Z","heartbeat_at":"2026-09-24T00:53:00Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xz2z","title":"The keyboard cell commit claims its basis at commit, not open: a value another client wrote while the editor was open is silently overwritten (0j0f's other door)","description":"Found while scoping improvise-3al1. improvise-0j0f was fixed only on the MOUSE path: `typed_cell` / `EditTarget` claims the record at the version it READ when the editor opened (`BasisCache::claim_as_read`). The KEYBOARD path does not. `resolve.rs::written`, reached from `commit` (Enter/Tab in Editing), calls `state.basis.claim([record_at_cursor(state)?])`, which states the record's CURRENT counter at commit time. The fold keeps running while the editor is open, so another client's write to the same record bumps that counter, and the commit claims the fresh one and is accepted.\n\nReproduced with a scratch test (not committed): a real Host, two sessions, North/Revenue = 1200, window 2x1.\n- Client A presses `i Backspace 4 2` (buffer \"12042\").\n- Client B writes North = 999 through the host. A folds the relays; A's editor stays open.\n- A presses Enter. The host ACCEPTS it: North ends at 12042. B's 999 is gone, A gets no Rejection, and B is told nothing.\nThe result is identical on gh/main 202e7eac and on the #227+#272 stack (9872cc93).\n\nThis is 0j0f's lost update, reached through the other door. It also blocks improvise-3al1: class-1 rejection, the thing force exists to answer, is essentially unreachable from the keyboard, because a concurrent write is overwritten instead of refused. The relay race (a write that reached the host before its relay reached the client) is the only keyboard case that can be refused.\n\nLikely fix: `OpenedEditor` captures what `EditTarget` captures (the record, the version read, and the generation, per improvise-vndi) when `opened_on` opens it, and `written` claims with `claim_as_read`. An awaiting editor (improvise-3uzg) would capture them when its seed settles.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:43:29Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T00:45:02Z","started_at":"2026-09-24T00:45:02Z","lease_expires_at":"2026-09-24T00:50:02Z","heartbeat_at":"2026-09-24T00:45:02Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-yc9r","title":"TUI opens the editor on a NonItem cell and refuses only at commit, stranding the user in Editing; the TUI/browser NonItem declines are unpinned (#265)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814. The PR introduces this.\n\n#265 adds CellId::NonItem and declines it in the TUI's value_cell_key/committable_cell_key (p, x, commit). But EditOrDrill (i / Enter) never asks, so the TUI opens an editor on a NonItem cell and refuses only at commit. The browser declines at open (resolve.rs open-editor arm), so the two front ends now disagree.\n\nProbe (reviewer's, not pushed): Region on page with its selection set to \"Mars\" (no item), Month on row, _Measure on column. At adb6b814:\n- `i` enters Editing.\n- `5`, then Enter: the mode stays Editing, the status is \"commit-cell-edit: Mars is no item of Region, …\", and no cell is stored.\n\nBecause the mode stays Editing, draw_bottom_bar draws the minibuffer instead of the status line (gotcha 21), so the user sees nothing happen. NonItem is the only CellId kind that opens and then refuses: EditOrDrill refuses Aggregate/fold and Derived at open.\n\nThe TUI declines are also unpinned. Each rival below was injected and run over the full workspace (`cargo test --workspace --no-fail-fast`), and each stays green:\n- RE: value_cell_key's NonItem arm returns the cell key, so p/x write the non-item key.\n- RG: committable_cell_key treats NonItem like Record. A commit on a stale page then writes a NEW orphan record {Region=Mars,…}, which is the gw18 shape #252 and #265 exist to stop.\n- RF: the browser's open-editor NonItem arm returns None instead of a SetStatus decline.\nThe PR body's claim that \"p, x and a commit refuse with that sentence in the terminal\" is untested.\n\nAC:\n- EditOrDrill refuses a NonItem cell at open, with non_item_refusal, as it does for Derived.\n- Tests fail under RE, RG and RF, with at least one TUI test driving a stale page or an orphan records row through `i` and Enter.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:59Z","created_by":"agent-review-252","updated_at":"2026-09-24T00:47:03Z","started_at":"2026-09-24T00:47:03Z","lease_expires_at":"2026-09-24T00:52:03Z","heartbeat_at":"2026-09-24T00:47:03Z","labels":["adversarial-review","pr-265"],"comments":[{"id":"ef641b5d-1b9c-50e1-ac19-e780bfb12869","issue_id":"improvise-yc9r","author":"agent-review-277","text":"From the adversarial review of PR #277 (head aab62a36), which conflicts with #265 in `committable_cell_key`. #277 routes that function through a new `cmd::core::uneditable`, which has no NonItem arm. I materialised the #277 x #265 merge. The exhaustive matches force a decision in both `uneditable` and `resolve::reopened_at`, and nothing forces the right one: a resolution that puts `CellId::NonItem` in `uneditable`'s editable (`None`) arm compiles, and the whole workspace passes (`cargo test --workspace --no-fail-fast`). That is this ticket's rival RG, reached through a merge. Under it a TUI commit on a stale page stores `Month=Jan, Region=Mars, _Measure=Revenue = 7`. The correct resolution is `Some(CellId::NonItem { category, item }) =\u003e Some(non_item_refusal(\u0026category, \u0026item))`, which refuses with \"Mars is no item of Region, …\". With that resolution, #277's reopen also declines NonItem, while `i` (EditOrDrill) still opens on it. That is this ticket's first AC.\n","created_at":"2026-09-24T01:29:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-4daa","title":"first_non_item clones every item name per coordinate per cell: grid_of ~10x slower in pivot, ~140x in records mode (#265 regression)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814, which is stacked on #252 (47b166ee). The PR introduces this.\n\n#265 makes project::cell_id call Model::first_non_item for every non-aggregate, non-derived cell, in both the pivot and records branches. first_non_item calls Model::effective_item_names(cat) once per coordinate. That clones every item name of the category into a fresh Vec\u003cString\u003e, and for _Measure it also walks the formulas, just to run one `contains`. The TUI calls grid_of over the WHOLE grid on every frame (draw.rs, gotcha 44), so the cost lands on the per-frame path.\n\nMeasured with `cargo test --release`, the same bench at both commits, on the reviewer's machine (Darwin, the orchestrator's host), mean of 5 and 3 runs:\n- pivot 150x120 (18k cells; A 150 items rows, B 6 x _Measure 20 cols, C 50 on page): 111 ms per grid_of at 47b166ee, 1086 ms at adb6b814 (~10x).\n- records mode, 2000 records x 2 measures, a Label category with 2000 items: 1.6 ms at 47b166ee, 225 ms at adb6b814 (~140x). The cost is quadratic in a per-row label category, which is exactly what import makes Label categories for.\n\nFix shape, verified: answer the same question without allocating. `self.category(cat).is_some_and(|c| c.items.contains_key(name))`, OR (for a measure category) any formula whose target_category is a measure and whose target == name. With that body the bench is 234 ms pivot / 3.1 ms records, and the improvise-command, improvise-core and improvise test binaries all pass. The residual ~2x over base on the pivot case is still worth a look. A shared helper `Model::is_item(cat, name)` would also serve App::non_item_refusal (the #252 write path, same clone per write).\n\nAC: first_non_item allocates nothing per call. A timing or allocation guard (or a documented bench) shows grid_of within noise of 47b166ee on both fixtures.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:57Z","created_by":"agent-review-252","updated_at":"2026-09-24T00:47:02Z","started_at":"2026-09-24T00:47:02Z","lease_expires_at":"2026-09-24T00:52:02Z","heartbeat_at":"2026-09-24T00:47:02Z","labels":["adversarial-review","pr-265"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-wpxr","title":"A wire write to an empty record resolves through the HOST's layout: refused RecordGone when the host sits on a view hiding it","description":"Found while reviewing PR #267 (improvise-3til) at head 2c16f7f2.\n\nApp::resolve_record tries the DataStore index first. If the record has no stored cells, it falls back to record_in_layout, which reads self.layout, and that is the HOST's active view. Since improvise-jytm, the host's view is not the client's. So a client's write to an EMPTY cell succeeds or fails depending on which view the host is on.\n\nWITNESS (a scratch test in ui/host.rs, run and then discarded). It uses host() (Region x _Measure, no stored cells) and adds a view \"Other\" that hides Region/South. The client connects and its Snapshot names view \"Default\", where South's empty cell is drawn.\n- Control: with the host on Default, the client's `host.model(SetCell(addressed(\"South\"), 3))` is Accepted.\n- Same setup with the host switched to Other (switch_to_view plus rebuild_layout): the identical write is Rejected(RecordGone). The client is still on Default and can see the cell.\n\nThis is the record-shaped twin of improvise-obtu, which covers aggregates only. gotcha 44 says a RecordHash is \"document-scoped\" and \"works from any view\". That holds only for records with stored cells. An empty record is layout-scoped, through the host's layout.\n\nLikely fix shape: resolve against a layout derived for the ASKING session's view, as obtu proposes for aggregates. Or resolve an empty record from the document's categories, independent of any layout. The second must still refuse a group, formula or aggregate cell (unwritable, ciw8/xur5).\n\nNOTE for tests: improvise-3til's test (PR #267) deliberately has no follow-up write. A fixture in which the host's two views differ in which records they show WOULD make a write discriminate the reordered rival. But the answer it would pin is this bug. Do not add such a write until this is fixed.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-wpxr","owner":"el-github@elangley.org","created_at":"2026-09-24T00:37:55Z","created_by":"agent-review-267","updated_at":"2026-09-24T00:39:12Z","started_at":"2026-09-24T00:39:12Z","lease_expires_at":"2026-09-24T00:44:12Z","heartbeat_at":"2026-09-24T00:39:12Z","labels":["adr-001","adversarial-review","pr-267","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pb30","title":"The column group toggle's cursor-follow is unguarded: three rivals in landing/place_cursor pass the whole workspace","description":"Found reviewing PR #263 (improvise-uv9e) at 2ea24074. The new test a_column_toggle_names_the_group_under_the_cursors_column pins only which group toggle-col-group-under-cursor names. The rest of the is_row:false path in ToggleGroupAtCursor (cmd/grid.rs) is untested. Each of these rivals was injected and run over cargo test --workspace --no-fail-fast, and each gave 1948 passed, 0 failed: (a) place_cursor's column arm swapped to (at, ctx.selected.1); (b) landing's column arm replaced with None, so there is no cursor-follow; (c) landing reads col_tuple(0) in place of col_tuple(ctx.selected.1). Rival (c) also needs a fixture with two column levels, because with one column category the prefix is always empty. The row half's equivalent is guarded by toggling_a_group_collapses_that_group_and_the_cursor_follows_it and its siblings. Fix: a two-level column fixture with the cursor in the second outer item on a nonzero inner run. Assert the cursor's column after the toggle is applied, and that the row is unchanged, off the origin.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:16Z","created_by":"agent-review-263","updated_at":"2026-09-24T00:35:56Z","started_at":"2026-09-24T00:35:56Z","lease_expires_at":"2026-09-24T00:40:56Z","heartbeat_at":"2026-09-24T00:35:56Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-p6v6","title":"An editor opened with i/a while an earlier write is unanswered seeds from the stale cache: i 9 Tab Esc i 5 Enter stores 15 where the TUI stores 195","description":"Found by the adversarial delta re-review of PR #227 at de4a7d78. This bug is PRE-EXISTING. I reproduced it against gh/main 202e7eac's protocol and command sources, with the same numbers. It is the same class as improvise-mdfc, but reached through a door that #227 does not touch.\n\nmdfc made the ADVANCE await whenever any awaited frame is in flight. The other door into the cell editor is `i`/`a`, resolved by edit_or_drill, which calls opened_on(cell.edit_text). That door still seeds from the client's cache with no in-flight check. The same holds for any other opener that reads seed_at/edit_seed while a write is unanswered, for example a click-edit, which is the improvise-ugj4 path; I did not probe that one.\n\nProbe: a real Host and ClientState. The fixture is one measure column. The cursor is on North = 1, which is the rightmost column. Keys: i 9, then Tab (writes 19 and reopens on the same cell), Esc and i, all sent before any answer arrives. Then the answers are delivered, then 5 Enter.\n- Browser: buffer \"1\" after i, and it stores North = 15.\n- TUI: buffer \"19\", and it stores 195.\nSo the browser overwrites the value the user has just committed, with a number built from the stale cache.\n\nThe reachable shape is a quick Esc and re-edit, or holding a key, over any latency. The fix is the same rule, stated once, for every editor opener rather than only for advanced(). An opener awaits the host's answer whenever `state.in_flight.iter().any(|s| s.awaited)`. The mdfc condition could move into opened_on itself, so that no opener can forget it.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:54Z","created_by":"agent-review-227c","updated_at":"2026-09-24T00:27:11Z","started_at":"2026-09-24T00:27:11Z","lease_expires_at":"2026-09-24T00:32:11Z","heartbeat_at":"2026-09-24T00:27:11Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rjaa","title":"check-ci-graph: a needed job whose keys are indented past 4 is read as content, so its if:/needs: are missed and nothing is refused","description":"Found in adversarial delta review of PR #228 at head 3eafd4de (the w0j4 fix, `2aafff3b..3eafd4de`).\n\nThe w0j4 refusal checks a job's keys only at indent 4. `_readable` returns True for any line indented deeper than 4 when it is not inside `steps:` or `needs:`. A job whose keys are indented 5, 6 or more is therefore read as all content. That is legal YAML, and GitHub reads it the same way. Its `if:` never reaches `if_jobs`, its `needs:` never reaches `needs`, and nothing is refused.\n\nPlanted on the real ci.yml. In each case `gate` is also added to pages-deploy's `needs`, and yq-go confirms both the `needs` edge and gate's `if: false`:\n- **A needed job with 6-space keys.** Add `gate:` with keys at 6 (`if: false`, `runs-on`, `steps:` with an item at 8), then give kb `needs: gate`. The gate exits 0, \"all 9 checkers.sh suites run in CI\", yet kb is skipped on a PR.\n- **The same with 5-space keys** and `needs: [gate]`: exit 0.\n- **A two-hop version.** A 6-space job `mid` holds `needs: gate`, and kb has `needs: mid`. That exits 0 too, so the transitive walk is cut as well.\n\nThe counted job itself cannot use this, because its steps are then not recognised and the gate fails loudly. The hole is every job it needs, directly or transitively.\n\nFix direction, in keeping with \"refuse, don't parse\": the first key line after a job header must be at indent 4, or it is refused. Any line at 5–8 that is not inside `steps:`/`needs:` must sit under a 4-space key with an empty value. Add a case for a needed job with 6-space keys.\n\nLoud false refusals seen in the same review (not bugs, noted for the doc): the gate also refuses three plain, valid styles, each by naming the line:\n- an indentless block list (`needs:` then ` - build` at 4);\n- a block list at 8;\n- a top-level `\"on\":`, the yamllint-truthy style.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:09:51Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T00:31:21Z","started_at":"2026-09-24T00:31:21Z","lease_expires_at":"2026-09-24T00:36:21Z","heartbeat_at":"2026-09-24T00:31:21Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9ww8","title":"A coordinate-rename retry after a crossing LayoutMoved renames whichever record is under the cursor (20ek's fix pins only RecordGone)","description":"Found reviewing PR #251 at head 29d2adef (improvise-20ek's fix). The 20ek fix pins the editor only on `Rejected(RecordGone(r))` where r is the refused rename's own record. But `stale_basis` checks the layout tier BEFORE the record tier, and a coordinate rename by another client bumps `layout_version` (measured 2 -\u003e 3). So when A's rename frame crosses B's rename in flight (A composed Enter before hearing B), the refusal is `LayoutMoved`, not `RecordGone`. `refused()` does not pin; the replay reopens the editor, `settle_opened` recaptures whatever record is now under the cursor at its current version, and the retry Enter renames THAT record with text typed for another.\n\nTwo probes, real Host, three_records_host (North 1, South 2, West 3), B's relays to A held until after A's Enter is answered:\n\n1. Source gone: A edits North, types Zed; B renames North -\u003e Yak. First answer `Rejected(LayoutMoved)`; editor reopens over South; retry: South -\u003e Zed. After: North None, South None, Zed 2.0, Yak 1.0.\n2. Source survives: A edits South (row 1), types Zed; B renames North -\u003e Yak, so South shifts to row 0 and West is under the cursor. First answer `Rejected(LayoutMoved)`; editor reopens over West; retry: West -\u003e Zed. South still exists, untouched, and West's 3.0 is now Zed. So the discriminator is not \"is the source gone\" at all.\n\nThe author's test `a_retry_after_record_gone_renames_no_other_record` delivers B's rename to A BEFORE Enter, so A's layout claim is current and only the record tier can fire; the crossing case, which only needs normal network latency, is untested.\n\nSuggested direction (not verified): after any refusal, a recapture must name the same record the refused rename named; if the cursor now sits over a different record (or none), decline as `Gone` does. `RecordMoved` on the same record stays a one-keystroke force. A test must hold B's relays until after A's Enter, and include the source-survives case.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:32:25Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T00:02:49Z","started_at":"2026-09-24T00:02:49Z","lease_expires_at":"2026-09-24T00:07:49Z","heartbeat_at":"2026-09-24T00:02:49Z","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-9ww8","depends_on_id":"improvise-20ek","type":"related","created_at":"2026-09-23T18:32:31Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-w0j4","title":"check-ci-graph fails open on quoted, anchored or multi-line needs: and on quoted/spaced if:/continue-on-error keys","description":"Found in adversarial delta review of PR #228 at head 2aafff3b (fix 1501def5, improvise-bbwa/1be2).\n\nThe bbwa fix reads `needs:` names as raw text, and job/step keys only in bare `key:` form. Several legal YAML spellings of the same workflow therefore fail open: the kb job is skipped on a PR, and the gate still exits 0 with \"all 9 checkers.sh suites run in CI\".\n\nReproduce on the real ci.yml:\n1. Add a job `gate` with a job-level `if: false`.\n2. Give `kb` one of the `needs:` spellings below.\n3. Add `gate` to pages-deploy's `needs`.\n\nyq-go confirms that kb needs `gate` in every case.\n\n- `needs: [\"gate\"]`: exit 0. The check compares against `\"gate\"`, quotes included.\n- `needs: 'gate'`: exit 0.\n- A block list with `- \"gate\"`: exit 0.\n- `needs: \u0026d [gate]` (an anchor on the value): exit 0.\n- `needs: \u0026d` followed by a block list: exit 0. The owner rule swallows the list.\n- A flow list over two lines, `needs: [build,` then `gate]`: exit 0.\n- Controls: `needs: [gate]`, `[ gate ]` and `[gate] # c` all exit 1.\n\nThe same key-recognition gap applies to the disqualifiers themselves. It is pre-existing from tvte, but it is the same class. Each of these exits 0:\n- a quoted job key, `\"if\": false`, on kb or on its step;\n- `'continue-on-error': true`;\n- `if : false`, with a space before the colon;\n- an explicit key, `? if` / `: false`.\n\nAlso found: the 1be2 self-test fixture puts the continuation two columns past the key, not at the boundary. So a rival that marks multiline only when `indent \u003e owner + 1` passes all 23 cases. On the real ci.yml, a 9-space `|| true` continuation (valid YAML, which yq reads as part of run:) then exits 0.\n\nFix direction:\n- Unquote scalar needs names.\n- Refuse, or report, any `needs:` whose value is not a plain name, a flow list on one line, or a block list of plain names.\n- Treat a quoted or spaced key as its bare form, or report it.\n- Add a boundary (owner+1) continuation case.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:08Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-23T23:58:21Z","started_at":"2026-09-23T23:58:21Z","lease_expires_at":"2026-09-24T00:03:21Z","heartbeat_at":"2026-09-23T23:58:21Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xd3q","title":"A relayed ResetCursor clobbers the placement of a frame the asker sent before the reframe was answered: client cursor splits from its mirror, and the next write lands on another record","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9. It is NOT #227's bug. The path touches none of #227's changed lines: host.rs is unchanged, and client.rs changes only the editor settle. The mechanism is #230's echo (improvise-wdmp). I did not re-run it at gh/main.\n\nA frame A whose write reframes the grid (a prune under prune_empty) is answered with ResetCursor, then an echo of A's own placement (wdmp's `asker_reset \u0026\u0026 places_cursor`). A frame B that the client sent BEFORE A's answer arrived was already folded optimistically. B's SetSelected is clobbered by A's relayed reset, and it is never echoed, because the host's asker_reset is per frame and B's handling saw no reset. The host's mirror keeps B's placement.\n\nProbe: a real Host and ClientState, fixture North/South/West/East = 1/2/3/4 on one measure, prune on. Keys i Backspace Enter (unanswered), then Enter (unanswered), then both answers are delivered. The client cursor ends at (1,0) while the mirror is at (2,0). The TUI is at (2,0). The next \"5 Enter\" writes West=35, where the TUI writes East=45: a write to a different record from the one the TUI touches.\n\nRelated to improvise-jpba (a refusal replay loses relays) and improvise-3kfc, but distinct: no refusal is involved. #246's relay queueing may or may not cover it. Check it there.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:47Z","created_by":"agent-review-227c","updated_at":"2026-09-24T00:09:44Z","started_at":"2026-09-24T00:09:44Z","lease_expires_at":"2026-09-24T00:14:44Z","heartbeat_at":"2026-09-24T00:09:44Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mdfc","title":"An untouched commit reopens from the cache while an earlier write is unanswered: pipelined Tab Tab at the right edge overwrites the value just committed (#227)","description":"Found by the adversarial delta re-review of PR #227 (improvise-fy9o) at head baabe1a9. It is a regression that #227 introduces. It is the 3tt4/3uzg harm again, reached by pipelining.\n\nresolve::advanced reopens the editor as OpenedEditor::awaiting() only when THIS commit writes. A commit that writes nothing (an untouched Enter or Tab) still seeds at once from the cache: `false =\u003e opened_on(seed_at(state, nr, nc), edit_mode)`. protocol.md §3 justifies this with \"Nothing changed, so the cache is current\". That is false while an EARLIER write is still unanswered: the cache still predates it. settle_awaited_seed never repairs the seed, because the editor it opened is not awaiting.\n\nReproduced in two ways:\n1. Real Chromium over the worker, harness fixture tall.improv, R01/Cost = 50 (the rightmost column), keys pressed back to back: i 9 Tab Tab 5 Enter. The browser stores 505 in 5 runs out of 5. The TUI stores 5095. So the browser overwrites the 509 the user has just committed.\n2. A Rust probe with a real Host and ClientState. The second Tab is sent before the first is answered. The browser buffer is \"1\" (stale) where the TUI's is \"19\", and North ends at 15 in the browser against 195 in the TUI. With prune_empty, a pipelined Enter Enter does the same: a stale seed at a renumbered position.\n\nEvery #227 test answers each key before the next one is sent (lockstep's sent_and_answered), so nothing pipelines. The fixture lacks the structure the hazard needs.\n\nA candidate fix, checked: await whenever any awaited frame is in flight, not only when this frame writes, i.e. `match writes || state.in_flight.iter().any(|sent| sent.awaited)`. With it, both probes' seed halves pass and the workspace stays at 1950 passed, 0 failed. protocol.md §3's \"Nothing changed\" argument and its \"never another record's number\" claim need correcting with it.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:15Z","created_by":"agent-review-227c","updated_at":"2026-09-23T18:29:18Z","started_at":"2026-09-23T18:29:18Z","lease_expires_at":"2026-09-23T18:34:18Z","heartbeat_at":"2026-09-23T18:29:18Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3kfc","title":"ClientState::refused never settles the unawaited entries it leaves at the front of in_flight, so the next answer pops the wrong entry (#246 relays make it reachable)","description":"Found by the adversarial review of PR #246 (improvise-jpba) at 542dd9ce.\n\n`ClientState::refused` pops the front of `in_flight` and replays, but never calls `settle_sent`. So an UNAWAITED entry (a view-only frame of this client's, or since #246 a queued relay) left at the front after the pop stays there. The NEXT answer (`accepted` or `refused`) then pops that entry instead of the awaited frame it answers. The queue is off by one from then on: the answered frame stays in flight and is replayed, and a later answer settles it.\n\nPre-existing on gh/main 4502ed2b for this client's own view-only frames. Measured: [F1 awaited, U = SetSelected(1,1) unawaited, F2 awaited], all three sent after another client's AddItem made F1 and F2 stale. Both are refused. The second refusal pops U, not F2. Client ends at (3,1), which is refused F2's placement. The mirror is at (1,1). F2 stays in `in_flight`.\n\n#246 makes it reachable from ANY relay, because a relay heard while a frame is in flight is queued as an unawaited `Sent { relayed: true }` behind the head. Measured at 542dd9ce with a real Host and ClientState:\n- Two in-flight Model frames F1 and F2. F2 ends with SetSelected(3,1).\n- Another client's reframe (AddCategory) relays ResetCursor while both are in flight. Queue: [F1, R, F2].\n- F1 is refused (LayoutMoved). Queue: [R, F2]. R is not settled.\n- F2 is refused. It pops R, which DROPS the relay jpba exists to keep. Queue: [F2].\n- Client view at (3,1), settled at (1,0), mirror at (0,0). F2's refused commands are still queued, and the next accepted answer will settle them.\n\nOn gh/main the same relay scenario also diverges (the relay is not recorded), but the queue stays aligned. On #246 it does not.\n\nCandidate fix, verified: add `self.settle_sent();` right after `self.in_flight.pop_front();` in `refused`. With it, the probe passes, and so does every workspace test at 542dd9ce. Suggested regression test: the two-in-flight probe above (host.rs tests, `exchange`/`connected_client` shape), asserting that client.view.cursor() == the mirror's and that in_flight is empty.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:34Z","created_by":"agent-review-221c-246","updated_at":"2026-09-23T17:59:34Z","labels":["adversarial-review","browser","pr-246"],"dependencies":[{"issue_id":"improvise-3kfc","depends_on_id":"improvise-jpba","type":"related","created_at":"2026-09-23T17:59:39Z","created_by":"agent-review-221c-246","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1be2","title":"check-ci-graph counts a --ci step whose run: is a plain multi-line scalar: a continuation line (|| true, ; exit 0) is appended to the call and never read","description":"At PR #228 head d6706774, ci_steps records a step's run: from its FIRST physical line only; the owner rule then skips deeper-indented continuation lines as content without disqualifying the step. YAML folds a plain scalar's continuation into the value: yq-go reads 'run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb' + next line ' || true' as '... --ci kb || true'. Planted on the real ci.yml, python3 scripts/check-ci-graph.py exits 0 ('all 9 checkers.sh suites run in CI') while every kb-row suite failure is swallowed. Same with '; exit 0'. The existing case 'a plain continuation line does not count' is the mirror (call on the continuation line); the call-first shape has no case. Fix: a step whose run: key owns any non-empty continuation line is not counted (report it). Also: _unrun's text says 'add it to ci.yml' but under the table design the fix is the ci_suites row in checkers.sh; the 228x245 composition prints that wrong advice.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:13Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:02:23Z","started_at":"2026-09-23T18:02:23Z","lease_expires_at":"2026-09-23T18:07:23Z","heartbeat_at":"2026-09-23T18:02:23Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-yb4b","title":"#239's spanning-fold totals ~double the TUI keypress path (paid twice per key); at 4.8k cells a held key stops keeping up, and h6z1 as filed does not reach stored-measure totals","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. Measured on an Apple M4 Max (16 cores, 64 GB) under shared load, with the\n`grid_projection` bench, min-of-N, alternating before and after, two runs each.\nMid-size cases were added locally.\n\nThe TUI pays `compute_col_widths` in `App::cmd_context` (for `visible_cols`) AND\na whole-grid `grid_of` in `draw`, on EVERY keypress, a pure cursor move\nincluded. The event loop draws once per key and does not coalesce. Since\n#239 each of them also evaluates a spanning fold per column. That is\n`evaluate_spanning` over every drawn row, which re-derives every member key and\nre-reads the store. So the per-keypress cost roughly doubles:\n\n| cells | per keypress before (gh/main 5932ad74) | after (9948aaf5) |\n|---|---|---|\n| 400 | ~1.3 ms | ~2.1 ms |\n| 1.2k | ~5.0 ms | ~8.2 ms |\n| 2.4k | ~14.6 ms | ~25.1 ms |\n| 4.8k | ~31.4 ms | ~52.4 ms |\n| 18k | ~200 ms | ~330 ms |\n| 90k | ~2.7 s | ~4.7 s |\n\n(col_widths + whole-grid, for example 18k: 78-91 → 135-157 ms, plus\n118-122 → 185-207 ms.) The user-visible line is key repeat, about 30 Hz, or\n33 ms. At 4.8k cells the TUI kept up with a held arrow key before (31 ms) and\nfalls behind after (52 ms): the cursor keeps moving after the key is released.\nThe threshold moves from about 5k cells to about 3k on the fastest hardware\navailable here. The browser pays the same `grid_in` on every scroll step and\nevery model change.\n\n**improvise-h6z1 as filed does not reach this.** Its memo is keyed by (fold\nkey, formula), and the bench's columns are stored measures under Sum, with no\nformulas. The added cost is `fold_stored`/`evaluate_unfolded` per member, which\nno formula memo touches. Levers that do reach it:\n\n- a per-grid-build value memo keyed by cell key and shared between the walk and\n the totals, since the spanning fold's members ARE the walk's cell keys in a\n dense grid;\n- computing the total once per frame rather than twice. `cmd_context` and\n `draw` each run it;\n- (argued against by design-principles §5, \"a derivation done twice\") combining\n the walk's row values for decomposable aggregators (Sum, Count, Min, Max),\n with the spanning fold reserved for Avg, formulas and text. If this is\n chosen it needs a property test against `evaluate_spanning`.\n\nFor comparison, the largest previously accepted keypress cost is gotcha 24:\n+2.65 ms at 18k (3.04 → 5.69 ms), with 56 ms at 90k called \"3.4 frames\".\nThis one is +20 ms at 4.8k and +130 ms at 18k.\n","notes":"USER RULING 2026-09-23: fix the speed first. #239 is held until the totals stop roughly doubling keypress cost.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:05Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T23:57:48Z","started_at":"2026-09-23T23:57:48Z","lease_expires_at":"2026-09-24T00:02:48Z","heartbeat_at":"2026-09-23T23:57:48Z","labels":["adversarial-review","performance","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-b5dp","title":"wm20 rulings (1) and (2) disagree for a formula column: a row's div/0 is drawn in the column and the total under it is a clean pooled number","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. A ruling conflict that needs the user, not a code bug.\n\nThe wm20 notes record ruling (2) as: \"An error in any row propagates, so the\ntotal is an error.\" PR #239 implements it only for STORED values. For a formula\ncolumn it deliberately does not, and says so in `context/code/command.md`: \"a\nrow's div/0 is not an input and does not propagate. Only the fold's own error\ndoes.\"\n\nMeasured at 9948aaf5. Region rows North, South and East, with Product on None\nand `Profit = Revenue - Cost`, `Margin = Profit / Revenue`. East has Revenue 0.\nThe rows draw `0.83`, `0.90` and `ERR:div/0`. The total draws **`0.80`**, not an\nerror. The same happens for `Share = Revenue / SUM(Revenue)`.\n\nThis is what ruling (1) implies. \"The column's cell as if every drawn row were\none fold\" is 48/60 = 0.80: collapsing every row into one group reads 0.80 too. So\nrulings (1) and (2) disagree for formula columns, and the PR chose (1) without\nthe user deciding. A user sees an error in the column and a clean total under\nit.\n\nAsk the user which one wins for a formula column. If (2) wins, the total must be\nthe error whenever any drawn row's formula errors, which is an extra per-row\nevaluation that the pooled fold does not do.\n","notes":"USER RULING 2026-09-23: pooled ratio. A formula total is evaluated over the pooled inputs, so a per-row formula error doesn't poison it; stored-value errors still propagate.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:04Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T23:57:49Z","started_at":"2026-09-23T23:57:49Z","lease_expires_at":"2026-09-24T00:02:49Z","heartbeat_at":"2026-09-23T23:57:49Z","labels":["adversarial-review","needs-user-ruling","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-53zz","title":"The shipped-documents scratch-repo tests inherit the caller's repository env and global config, so under a worktree hook they stage files into the real index","description":"Found reviewing PR #243 (improvise-c0aj) at head 228f130e.\n\ntests/shipped_documents_load.rs shells out to git with the caller's environment and global config. Two consequences, both measured:\n\n1. **An inherited GIT_DIR writes into another repository's index.** Git exports GIT_DIR and an absolute GIT_INDEX_FILE to hooks when run from a linked worktree (measured: a pre-commit hook in a `git worktree add` checkout sees GIT_DIR=.../.git/worktrees/wt and GIT_INDEX_FILE=.../worktrees/wt/index; a normal checkout exports only a relative GIT_INDEX_FILE, pre-push exports neither, rebase -x exports neither). Every wave agent works in a linked worktree. With GIT_DIR set, `git -C \u003cscratch\u003e init` re-initialises the outer repo and `git -C \u003cscratch\u003e add .gitignore web/demo.improv` stages the scratch files into the OUTER index, because GIT_DIR wins over -C discovery and the worktree defaults to cwd. Measured: `GIT_DIR=\u003cthrowaway\u003e/.git cargo test --test shipped_documents_load` left the throwaway index holding `.gitignore`, `docs/model.improv.gz`, `secret/forced.improv`, `web/demo.improv`, and three of the four shape tests still reported ok. A pre-commit hook that runs cargo test from a worktree would therefore replace the commit's root .gitignore with `*.improv\\n!web/demo.improv\\n` and add three junk files. Latent: no hook in this repo runs cargo test today.\n\n2. **A global core.excludesFile fails a shape test falsely.** With GIT_CONFIG_GLOBAL pointing at a config whose excludesFile contains `*.gz` (a common global ignore), `a_gzipped_document_is_walked` fails at `git add` (\"paths are ignored by one of your .gitignore files\"). commit.gpgsign and core.hooksPath do not matter: no test commits, and add fires no hooks.\n\nFix: in run_git and shipped_documents, remove the repository-local env vars (`git rev-parse --local-env-vars`: GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_COMMON_DIR, ...). For the scratch repos also set GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1. Rival: re-run with GIT_DIR pointing at a throwaway repo and assert its index stays empty.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T16:03:57Z","created_by":"agent-review-243","updated_at":"2026-09-23T18:02:09Z","started_at":"2026-09-23T18:02:09Z","lease_expires_at":"2026-09-23T18:07:09Z","heartbeat_at":"2026-09-23T18:02:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3uzg","title":"Under prune_empty, a commit that prunes its own row reopens the browser editor on the renumbered neighbour's stale cache value (#227 regression)","description":"#227 (improvise-fy9o, head a093d126) regression. Found by the adversarial review of the 3tt4 fix.\n\n`resolve::advanced` seeds the reopened editor from the client's pre-write cache (`seed_at`) everywhere except the cursor's own cell. `landed_seed`'s doc says: \"The cache is stale only where the frame just wrote, which is the cursor's own cell\". That is false under `prune_empty`. A commit that empties the last value in a row (or a column) prunes that row. The grid renumbers, so the cell the advance lands on is a different cell from the one the cache holds at that position. The TUI's `CommitAndAdvance` puts `RebuildLayout` in front of `EnterEditAtCursor` (gotcha 23), so the TUI seeds from the renumbered grid.\n\nRepro (lockstep harness): three regions North/South/West = 1/2/3, one measure, `prune_empty = true`, cursor (0,0), window (3,3).\n- `i Backspace Enter`: the browser buffer is \"2\" (South, the stale cache at row 1). The host buffer is \"3\" (West, which row 1 now is). Both cursors are at (1,0).\n- Then a projection arrives: a fresh cache, with the client's ViewState kept. `5 Enter` then leaves the browser document at West=25 and the host at West=35. The browser writes a value built from another record's number.\n- At gh/main 5932ad74 both ends agree (buffer \"\" on both, West=5 on both). This PR introduces the divergence. Before fy9o, the welded clear-buffer blanked both seeds.\n\nThe same mechanism applies to a column pruned by Tab. That case was not run.\n\nSame harm shape as improvise-3tt4: \"the reopened editor shows the next cell's current value\" fails wherever the write reshapes the grid. A client cannot tell from its cache that a write will prune. So the fix probably cannot be a smarter `seed_at`. Options: defer the seed to the projection (`settle_editor` already seeds an absent buffer), or seed blank whenever the frame writes.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:45Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:45Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-st8q","title":"check-ci-graph fails open on a bare checkers.sh line inside a heredoc, multi-line string, or folded/plain-multiline run: scalar","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py reads a run: value one physical line at a time. It does not track a multi-line quoted string or a heredoc body, and it does not apply YAML's folded (\u003e) or plain-multiline scalar rules. So a bare `./scripts/checkers.sh` on a line of its own reads as a bare invocation, which checkers.sh --would-run answers as EVERY suite. A suite that no step runs then passes, silently. This is the same fail-open class as improvise-maax item 1 (a bare mention reads as every suite runs), reached through a different mechanism.\n\nDemonstrated against the real ci.yml, with a planted suite zz-new (scripts/zz-new plus zz-new-test.sh) that no step runs. The gate exits 1 on the plant alone. It exits 0 after either of these edits:\n1. A step-summary heredoc (a realistic step):\n run: |\n cat \u003e\u003e \"$GITHUB_STEP_SUMMARY\" \u003c\u003c'EOF'\n To reproduce locally, run\n ./scripts/checkers.sh\n from the repository root.\n EOF\n2. Folding the kb job's last step as `run: \u003e` with `./scripts/checkers.sh` on one line and `pr-watch release-preflight pr-reviews fmt-workspaces` on the next. yq confirms that the YAML value is `./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces`, so only four suites run.\n\nThe same result comes from a multi-line double-quoted string that contains the bare line, and from a plain multi-line scalar (`run: ./scripts/checkers.sh` followed by an indented `kb`). A third mechanism gives the same result: `\"--command\" in words[k:]` matches any command, so `echo use --command ./scripts/checkers.sh` also reads as every suite.\n\nThe claim in context/code/tooling.md, \"A line shlex cannot parse contributes nothing, which fails closed\", is true of that one line only. The following lines are then misread. The same paragraph also says \"a CI step that spells it anywhere is a usage error rather than a green step that ran nothing\". That is false for the first position: `checkers.sh --would-run kb` in a step exits 0 and runs nothing. The gate still catches it, because asking `--would-run --would-run kb` is exit 4, so nothing is counted. But the step itself is green.\n\nFix direction (fail closed): tokenise the whole script rather than each line, so quotes span lines. Contribute nothing from a heredoc body, or refuse a script that contains `\u003c\u003c`. Fold `\u003e` and plain-multiline values the way YAML does. Honour `--command` only when the command word is `nix`. Add each shape as a SUITE_SELF_TEST case that requires a suite run only through the shape.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:47Z","created_by":"agent-review-228b","updated_at":"2026-09-23T15:48:08Z","started_at":"2026-09-23T15:48:08Z","lease_expires_at":"2026-09-23T15:53:08Z","heartbeat_at":"2026-09-23T15:48:08Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jpba","title":"A client refusal replays from a settled slice that never folded the host's relays: after a reframe, the next refused frame splits the client cursor from its mirror","description":"Found while adversarially reviewing PR #230 (improvise-wdmp) at head 93c455db. It is pre-existing: reproduced at gh/main 194d8f52. It is not caused by #230.\n\n`ClientState::refused` rolls back by `replay()`: `view = settled.clone()`, then re-folding every in-flight frame. But `settled` only ever absorbs the client's OWN frames, in `accepted()`. A command the host RELAYS to the asker (`ResetCursor` from `adopt_cursor_epoch`, the per-client `SwitchView` from `adopt_deleted_views`, and now #230's echoed placements) is folded into `view` only. The next refusal therefore rewinds the client past every relay it has heard, while the host's mirror keeps them.\n\nReproduction, as a host test in ui/host.rs with a real `Host` and a real `ClientState` (it folds every frame addressed to the asker):\n1. The asker sends `[SetSelected(1,0)]`. It is accepted, and client, settled and mirror are all at (1,0).\n2. The asker sends `[ChangeMode(Normal), AddCategory(\"Product\")]`. The mirror is reset to (0,0) and `ResetCursor` is relayed, so the client view is (0,0). `settled` stays at (1,0).\n3. The asker sends an `AddCategory` whose basis was taken before step 2. It is refused (stale layout), and `refused()` calls `replay()`.\nResult: the client is at (1,0) and its mirror at (0,0). This is the client/mirror cursor split improvise-wdmp closes, reopened by any later refusal. With #230, the placed case also leaves `settled`'s row/col offsets un-reset: `[SetRowOffset(1), AddCategory, SetSelected]` gives settled row_offset 1 and mirror 0.\n\nThe likely fix: relayed commands must reach `settled` too. For example, `fold` for a server-originated command also reduces into `settled`, or replay composes relays in arrival order. Acceptance is the test above, together with a rival that restores the current `fold`.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:18Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:34:15Z","started_at":"2026-09-23T15:34:15Z","lease_expires_at":"2026-09-23T15:39:15Z","heartbeat_at":"2026-09-23T15:34:15Z","labels":["adversarial-review","browser","pr-230"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-maax","title":"check-ci-graph suites_run fails open: non-command mentions, --list, and --self-test's discarded names read as suites run","description":"Found in adversarial review of PR #228 (head cee61b94). `scripts/check-ci-graph.py::suites_run` fails OPEN on four shapes, each planted and run against `suite_verdict([\"kb\",\"zz-new\",\"checkers\"], suites_run(text))` with `zz-new` run by no step:\n\n1. A bare `scripts/checkers.sh` in text that is not a command reads as \"every suite runs\" (verdict []): an inline comment `run: echo skipped # ./scripts/checkers.sh` (ChatGPT Codex's P1 on the PR), a step name `- name: Runs scripts/checkers.sh`, and `run: echo scripts/checkers.sh`. Only a line whose FIRST non-space char is `#` is skipped.\n2. `checkers.sh --list` alone returns ALL, but `--list` prints and exits without running anything (checkers.sh main: `--list) suites; return 0`).\n3. `checkers.sh --self-test zz-new kb` is counted as running checkers, zz-new and kb (verdict []), but checkers.sh sets `names=(checkers)` when --self-test is given and DISCARDS the names. Consolidating the CI steps into one `--self-test kb pr-watch ...` line would silently stop running every named suite with the gate green.\n4. `checkers.sh --list kb zz-new` counts kb and zz-new as run; nothing runs.\n\nAlso (fails closed, loud but wrong): only the first invocation on a line counts (`./scripts/checkers.sh kb \u0026\u0026 ./scripts/checkers.sh zz-new` reports zz-new missing), and quoted names (`'zz-new'`) are read with their quotes.\n\nFix direction: model checkers.sh's own argv semantics (--list =\u003e runs nothing, --self-test =\u003e {checkers} only whatever else is named, -h/unknown flag =\u003e runs nothing) instead of \"no positional names =\u003e ALL\"; strip a shell comment before matching; only look inside `run:` values. Each shape above should become a SUITE_SELF_TEST case.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:35Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:27:36Z","started_at":"2026-09-23T15:27:36Z","lease_expires_at":"2026-09-23T15:32:36Z","heartbeat_at":"2026-09-23T15:27:36Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-znuk","title":"A pivot client's first write into an empty cell is RecordGone when the host sits on a records view","description":"Found by agent-collapse-fu while scoping improvise-8id2. Creating an EMPTY cell resolves its record only through the host's layout (record_in_layout). So when the host is on a records view and a pivot client writes into an empty pivot cell, the record doesn't resolve and the write is refused RecordGone. Two clients on different views of one document is supported (gotcha 29), so this is a real multi-client failure. Resolve an empty-cell write against the view the CLIENT was shown (the view the command names), not the host's. Pin it with a Host-level test with the host on records and the client on pivot.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:17:41Z","created_by":"orchestrator","updated_at":"2026-09-23T15:17:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-milr","title":"RenameRecordCoordinate overwrites a destination record records_named does not name: a concurrent write to it is lost with no Rejection","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nModelCommand::RenameRecordCoordinate WRITES the destination record (when the new coordinate lands on an existing record, per improvise-kndq's overwrite rule) but records_named names only the source record. So the basis check never asks about the destination, and a concurrent write to it is lost silently.\n\nRepro (three_records_host: North=1, South=2, West=3, records mode, a real Host):\n1. A opens the coordinate editor on North and types \"South\".\n2. B writes South/Revenue = 999 (accepted). A folds the relay.\n3. A presses Enter: ACCEPTED. South/Revenue is now 1 (North's value). B's 999, which A was never shown when it opened the editor and never claimed a version of, is gone. No Rejection, no status.\n\nThis is the lost update ADR 001's record-level basis exists to prevent, and it is new with the wire form: before #221 the overwrite was reachable only from one TUI user's own keys, where there is no concurrent writer. improvise-kndq decides the POLICY for a collision (refuse, merge or overwrite); this ticket is narrower and stands whatever kndq decides unless kndq picks \"refuse\": a command that writes a record must name it in records_named, or the check is a force by omission (gotcha 43). The obstacle is that a client may not be able to claim the destination (it may be off-screen, unpublished, or not exist), so naming it probably means \"refuse if the destination exists and the frame's basis does not state it\", which is RecordUnstated on the destination.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:51Z","created_by":"agent-review-221","updated_at":"2026-09-23T15:13:31Z","started_at":"2026-09-23T15:13:31Z","lease_expires_at":"2026-09-23T15:18:31Z","heartbeat_at":"2026-09-23T15:13:31Z","labels":["adversarial-review","pr-221"],"dependencies":[{"issue_id":"improvise-milr","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T15:06:55Z","created_by":"agent-review-221","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-20ek","title":"A coordinate-rename retry after RecordGone renames whichever record moved under the cursor (browser, fdk1)","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nA browser client's coordinate-rename retry after Rejected(RecordGone) renames a DIFFERENT record: the one that has since moved under the cursor.\n\nRepro (probe in crates/improvise-wasm-client/tests/browser_round_trip.rs shape, three_records_host: North=1, South=2, West=3, records mode):\n1. A opens the editor on North's Region coordinate (row 0) and types \"Zed\".\n2. B renames North -\u003e Yak (RenameRecordCoordinate, accepted). A folds the relay: North's row is gone, so row 0 is now South.\n3. A presses Enter: correctly Rejected(RecordGone(North)).\n4. The refusal replays the settled slice, so A is editing again at (0, coordinate) with buffer \"Zed\". settle_opened sees (editing, opened=None, nothing awaited) and captures the record NOW under the cursor, South, at its current version.\n5. A presses Enter again: accepted. South is renamed to Zed. South is gone.\n\nMeasured: after the refusal, opened = South's RecordHash at version 0, cell text under the cursor \"South\", buffer \"Zed\". After the second Enter, Region=South/Revenue is None and Region=Zed/Revenue = 2.\n\nWhy it matters: context/code/clients.md (\"A retry after a refusal is a force\") argues the retry is the one-keystroke force RecordMoved promises (gotcha 43). That argument holds only when the record is still under the cursor. For RecordGone, gotcha 43 says there is nothing to force (\"force never creates structure\"), and the retry here is not a force of the refused edit at all: it applies text typed for North to South. The user did see an editor over South's row, but its buffer is the text they typed for North, so one Enter rewrites a record they never opened.\n\nCandidate fixes (not decided): keep `opened` across a refusal that named that record (so the retry is RecordGone again and the user must Esc and reopen); or clear the buffer/leave editing on RecordGone; or capture only when the cursor's record equals the refused one. Any fix must keep a_second_rename_after_a_reorder_renames_the_row_the_host_shows green, which pins the opposite capture after an ACCEPTED rename (an injected \"never clear opened\" rival makes this probe pass and that test fail, so the two pull in different directions).\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:49Z","created_by":"agent-review-221","updated_at":"2026-09-23T15:13:29Z","started_at":"2026-09-23T15:13:29Z","lease_expires_at":"2026-09-23T15:18:29Z","heartbeat_at":"2026-09-23T15:13:29Z","labels":["adversarial-review","pr-221"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.13","title":"ADR 007 step 2c: displays in status messages and the view title","description":"The third slice of improvise-cq1q.3 (ADR 007 step 2): the remaining places that show a key to a person.\n- Status and minibuffer messages that interpolate a category, item or view name: about 10 sites in improvise-command (ui/effect.rs, cmd/panel.rs, cmd/commit.rs, cmd/core.rs), plus `non_item_refusal` in improvise-protocol and the anyhow errors in workbook.rs that reach the status line.\n- The view title: `grid.rs` \" View: {}\" in the TUI, and `render.rs` `view_name` in the browser. Both read the key from `ViewState::active_view`; they need the active view's display from a projection (the ViewListCache already carries it since the first slice).\n\n## Acceptance Criteria\n- Tests first: with a view and a category displayed differently from their keys, the title and a representative status message (for example, the axis-change status) show the display.\n- Rival: interpolating the key. The test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","updated_at":"2026-09-25T19:10:41Z","dependencies":[{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T19:10:41Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.13","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T19:10:49Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.10","title":"ADR 004 spells reverse selection as background=accent, which REVERSED draws inverted; the accent must be the foreground","description":"Found in the post-merge review of PR #319 (merged head 3583359a). This needs the user,\nbecause it is a correction to accepted ADR 004 text, not to code.\n\nADR 004 clause 3 and the vocabulary section say the twelve `fg(Black).bg(accent)`\nselection sites become faces that inherit `selection` (which carries `reverse`) and\n\"override `background` from its own surface's accent\". That spelling is inverted.\nREVERSED (SGR 7) swaps the cell's foreground and background. So `bg = accent` plus\n`reverse` draws accent-coloured text on the terminal's default-foreground colour. It\ndoes not draw default-background ink on the accent. Clause 5's CSS rule does the same\nswap (`.reversed { color: var(--face-bg) }`), so the browser inverts identically.\n\nTo reproduce \"ink on accent\" with reverse, the derived face must state the accent as\nits FOREGROUND, leave background unstated, and set reverse. That is also what makes the\nlight-terminal fix in clause 3 work: the ink becomes the terminal's own background.\n\nThis blocks improvise-nn4s.6. If the ADR is migrated as written, every selection\nhighlight renders inverted. It is also not a no-op on a TestBackend buffer. Clause 8\nrequires that change to be named as an exception, with who decided it. So nn4s.6 needs\nboth the corrected spelling and the user's ruling on the exception.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:00Z","labels":["adr","adversarial-review","needs-user","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.10","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:00Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"04cf9807-5d5e-5926-a40c-56dfa32f62a7","issue_id":"improvise-nn4s.10","author":"Edward Langley","text":"USER RULING 2026-09-25, verbatim: 'ADR 004 is allowed to specify visual changes that make the UI more consistent'. So clause 8's exceptions are not limited to category-add: the selection faces using reverse (with the accent as FOREGROUND, per this ticket's correction) are an allowed visual change. Unblocks nn4s.6 once the ADR text is corrected.","created_at":"2026-09-25T17:15:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-cq1q.11","title":"ADR 007 plan: cq1q.4 (re-key) omits two input edges it reaches before their own steps — the old-format .improv reader/writer (before .5) and wire operands/projection names (before .7/.8)","description":"Found by the adversarial review of PR #318 (head 689480bf), while checking the cq1q plan's ordering.\n\nimprovise-cq1q.4 (re-key storage by id) lists these input edges where display→id resolution moves: minibuffer commits, the command-line/script parser, and formula reference resolution. Two more edges reach the re-key before their own steps land, and the ticket does not name either.\n\n1. **The .improv reader and writer, before cq1q.5.** After .4, the keys are slugs (`region`, `east`) and the displays are the typed names. Until .5 lands, the only file format is the old one. So:\n - the old-format loader must already assign ids by slugify and resolve references against displays (clause 5's old arm, currently .5's scope);\n - the writer must write displays, not keys.\n\n If the writer writes keys, every save between .4 and .5 lowercases and strips the user's names (`Region` → `region`, `Café` → `caf`), and nothing can restore them.\n\n2. **Wire operands and projection names, before cq1q.7/.8.** After .3, the browser's panels show displays taken from the projections, and the browser composes `SetAxis { category, .. }`, `HideItem` and the rest from the same strings. After .4 the host keys by id, so between .4 and .8 every such command names a key that does not exist. That covers almost every real name, because slugify folds case. The fix is one of:\n - `ui/reduce.rs::model_effects` resolves display→id as an interim input edge;\n - the projections keep carrying the key alongside the display.\n\n ViewState's name-keyed cursors, expanded_cats and view history (cq1q.7) meet the same seam.\n\nADR 007 clause 11 orders the migration the same way (re-key, then persistence, then projections, then vocabulary), so this is a gap in the step boundaries rather than in the ADR's decisions. What is needed: .4's acceptance criteria say the suite stays green, and they name these two interim edges. Or .4 is split so that each step leaves main shippable. A test for edge 1: a document with display `Region` survives save/load between .4 and .5 with its display intact.\n\nA smaller point: cq1q.3 depends on cq1q.2, but adding a display field needs neither slugify nor the allocator. The dependency serializes work for no reason.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:51:52Z","closed_at":"2026-09-25T17:51:52Z","close_reason":"PR #318 merged (6da6a040, head 7ddc50ac); reviewed PASS (issuecomment-5836573405); cq1q.11 closed by the re-sequenced bead graph","dependencies":[{"issue_id":"improvise-cq1q.11","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T06:19:38Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ca207052-0f62-5f31-b8d9-3d4a3a30b68f","issue_id":"improvise-cq1q.11","author":"agent-adr007","text":"agent-adr007: resolved by reordering, with no new beads. (1) cq1q.7 (projections and ViewState carry an id field) now depends only on cq1q.3 and lands before the re-key, using the display-first trick: id == key == name until cq1q.4 changes what the key is. So the wire never names a key that does not exist, and no interim resolver in model_effects is needed. (2) cq1q.4 now owns the old-format reader and writer edge: the loader slugifies, the writer writes displays, and a save/load test keeps the displays Region and Café intact. (3) The cq1q.3 -\u003e cq1q.2 dependency is removed. cq1q.4 now depends on .2, .3 and .7, and cq1q.8 depends on .4 and .7. Details are appended to the notes of cq1q.4 and cq1q.7.","created_at":"2026-09-25T06:42:12Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8jk6.16","title":"A --measure value that does not read as a number is dropped silently and the import exits 0","description":"Found in the adversarial review of PR #321 at efaa5050. A value in a column the user forced to Measure that does not read as a number is dropped with no warning, and the import exits 0. This is the same \"saved less than was asked\" family that context/code/cli.md section 13 closes for --category, --formula and kept-nothing, but it is not covered there.\n\nRepro, identical on main at c91cc055 and on the PR head:\n\n printf 'k,v\\na,abc\\nb,7\\nc,\"1,000\"\\n' \u003e nn.csv\n improvise import nn.csv --no-wizard --measure v -o nn.improv; echo \"exit=$?\"\n\nWhat happens: only `_Measure=v, k=b = 7` is written, \"Saved to\" is printed, and the exit status is 0. The `abc` and `1,000` values are gone with no report. Row `a` also loses its whole record, which is the improvise-8jk6.14 loss, but here the value was present and could not be read. That is a different reason.\n\nSince 8jk6.4, `csv_parser::number_in` is the one gate that decides whether a measure's CSV text is a number (via `Scalars::number` in `Plan::place`). So there is one place to count what it refused. Decide: warn with a count and the column (the 5j9 and 8jk6.6 partial-skip shape), or refuse headless. A thousands separator such as \"1,000\" is worth deciding about explicitly, since `number_in` does not read it.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:14:58Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.16","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:14:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-10o9","title":"Formula WHERE/IS naming a GROUP matches none of its members, so a date hierarchy cannot be crosstabbed by formula","description":"The crosstab half of improvise-8jk6.3 does not exist yet. The user: \"I think dates should usually be one category and we can use formulas to crosstab\". But a formula `WHERE` or `IS` that names a GROUP matches none of that group's members. Both compare the record's coordinate to the name exactly, and no record carries the group's name.\n\nMeasured by the adversarial review of PR #322 at 6b910889. The file was a headless import of a two-year CSV with `--extract Date:Year --extract Date:Quarter --extract Date:Month`, so `Date` has items `2024-01`… in groups `2024-Q1`… under years `2024`/`2025`:\n\n- `Y24 = SUM(Revenue WHERE Date = |2024|)` gives -0.00 on every row. The expected value is 1111.\n- `IsY = (Date IS |2024|) * Revenue` gives 0 on every row, including the 2024 months.\n- Naming an item works: `SUM(Revenue WHERE Date = |2024-01|)` = 11, and `(Date IS |2024-01|) * Revenue` = 11 on the 2024-01 row.\n\nThe PR author measured the same and reported it for filing (PR #322 body, and `context/code/formula-and-io.md` \"Crosstabs by formula are not available yet\").\n\nShape: resolve a name that is a group of the filtered category to its covered items, as the fold already does via `Category::items_in_group` (felo.6). Decide the item-vs-group name ambiguity first: improvise-vwc7 says an item may share a name with a group of its category. Tests first: a 3-level Year\u003eQuarter\u003eMonth fixture, with a WHERE and an IS on each level.\n","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:18Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-10o9","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.9","title":"checkers.sh's env allowlist drops the Nix linker variables, so no suite can link Rust on macOS","description":"Found in the PR #313 review (issuecomment-5827613256): inside the .#ci shell on macOS, 'env -i HOME PATH TMPDIR cargo build --bin improvise' fails with 'ld: library not found for -liconv' (a trivial crate still links). scripts/checkers.sh scrubs the environment down to HOME/PATH/TMPDIR plus three NIX_* variables and drops the ones the Nix cc/linker wrapper needs (NIX_LDFLAGS, NIX_CFLAGS_COMPILE and friends). So any suite that builds Rust fails on macOS under checkers.sh; repo-stats-test.sh worked around it by requiring a prebuilt improvise. context/code/tooling.md (~line 911) overstates the allowlist. Fix: keep the wrapper's variables (or re-enter the dev shell inside the scrub); test by building a crate with a system-library dependency under checkers.sh's env on macOS. CI (Linux) is unaffected as far as known.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:00:52Z","labels":["ci","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.9","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T06:00:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.12","title":"apply_effects reports only a batch-wide refusal: record, per effect, whether it refused AND which of its parts landed (prerequisite of ADR 006's recorder)","description":"App::batch_refused (crates/improvise-command/src/ui/app.rs:454) is one flag per apply_effects batch, set by App::refuse and reset at batch start. ADR 006 (PR #315, clause 2) commits a recorded line when its effect CHANGED something replay compares, narrowed to the parts that landed, and never commits a line for an effect that changed nothing. So the recorder needs a per-effect outcome that is NOT a single refused/landed bit: an effect can refuse and still write. ApplyAndClearDrill refuses an empty category edit (RecordRewrite::of, crates/improvise-command/src/ui/effect.rs:810-812) yet keeps that row's other writes (compose :873-880, effects() :931-945); only a deleted category drops the whole commit (merged :901-913). Reading the batch flag after each effect also cannot tell a later effect's outcome once an earlier one refused (the shape improvise-bj61 reports). Wanted: a per-effect outcome observable in the apply_effects loop that says (a) whether the effect refused, (b) whether it changed anything replay compares: the loop's content/config fingerprints (app.rs:1037-1052, compared only after CellData/LayoutGeometry effects), a move of dirty, a file written (Save/SaveAs/ExportCsv), OR a move of document_generation, which is the only signal a WholeWorkbook effect (LoadModel, ImportJsonHeadless, AcceptWizardModel, all via install_workbook, app.rs:594-604) gives when dirty is already raised, and (c) which of its parts landed. Tests: a MIXED drill commit (one empty category edit plus a value edit in the same row) reports refused AND written, with only the value edit as landed; a batch [refusing effect that writes nothing, landing effect] reports each correctly, and in the reverse order too; a LoadModel applied while dirty is already true reports changed.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:42:52Z","created_by":"agent-adr006","updated_at":"2026-09-25T06:39:58Z","dependencies":[{"issue_id":"improvise-31oe.12","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:42:52Z","created_by":"agent-adr006","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3o2c","title":"view_shape's fold hash is unguarded below a hierarchy's root","description":"Found reviewing PR #317 (improvise-felo.6). App's view_shape (improvise-command/src/ui/app.rs) hashes the collapsed groups of every drawn item's chain, at every depth; observe_reshape diffs it to bump layout_version, the basis tier of ADR 001. Injected fault: restrict the walk to the root, (0..chain.len().min(1)). The whole workspace suite stayed green (2169 tests), so toggling a quarter under an open year could stop bumping layout_version with nothing failing. A client whose frame was composed before the fold would then pass the layout-tier check. Want: a test that folds a depth-1 group and asserts layout_version moved, and fails under that fault. Related: improvise-9v0j.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:46Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:46Z","labels":["core","tests"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.10","title":"Eight view-scoped commands (set-axis, set-page, toggle-group, hide-item, show-item, set-format, transpose, toggle-prune-empty) take no view operand: the command-language half of improvise-lltt","description":"Found reviewing PR #315 (ADR 006, head 50234b49). Citations are at gh/main b639266b.\n\nEight view-scoped commands in the command language build `ViewTarget::Active`, and take no view operand:\n\n- `set-axis`, `set-page`, `toggle-group`, `hide-item`, `show-item`, `set-format` (`crates/improvise-command/src/command/cmd/effect_cmds.rs:338`, `:351`, `:364`, `:377`, `:390`, `:412`)\n- `transpose` (`command/cmd/cell.rs:711`)\n- `toggle-prune-empty` (`command/cmd/grid.rs:882`)\n\nWhat each one changes therefore depends on which view the running session is on. The wire closed the same gap with improvise-lltt, where every view-scoped `ModelCommand` carries `view: String` (gotcha 29), but the command language, which is the first-class product surface for `improvise cmd`/`script`, did not. So a script cannot say \"set the axis in view B\" without first `switch-view B`, which is session UI state. Nor can any record of a view-configuration change be replayed without replaying the UI. There is also no explicit-operand command at all for `CycleAxis` (only `cycle-axis-at-cursor`/`cycle-axis-for-tile`).\n\nNot covered by improvise-31oe.2 (unknown names are silent no-ops) or improvise-31oe.3 (pipe quoting, extra arguments). Neither mentions a view operand. improvise-lltt is the wire half and is closed.\n\nFix direction: an optional view operand on each command, defaulting to the active view so interactive use is unchanged. It resolves strictly: a view the document lacks is refused, not a fallback, as wpxr ruled for `SetCell`/`ClearCell`. The spelling (a trailing argument, or a flag) has to agree with improvise-31oe.3's argument-count rules, so coordinate with it. Demonstrate first: a script that creates two views and runs `set-axis` naming the non-active one must change that view and leave the active one alone.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:21:41Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:21:41Z","dependencies":[{"issue_id":"improvise-31oe.10","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:21:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.9","title":"set-cell cannot write text that reads as a number: \"100\" and |100| both miss, and inf/nan become Numbers","description":"Found reviewing PR #315 (ADR 006, head 50234b49), and confirmed by running `improvise cmd` at b639266b.\n\n`set-cell` decides a value's type by trying `f64` first (`crates/improvise-command/src/command/cmd/effect_cmds.rs:224-228`): `args[0].parse::\u003cf64\u003e()` becomes `CellValue::Number`, and anything else becomes `Text`. There is no spelling that forces text, so:\n\n- `set-cell 100 Region/East _Measure/Code` stores Number(100).\n- `set-cell \"100\" …` stores Number(100). The parser strips the double quotes before the command sees them.\n- `set-cell |100| …` stores Text(\"|100|\"): the command language does not read pipe quoting (improvise-31oe.3), so the pipes are kept literally, and the file writes `|\\|100\\||`.\n- `set-cell inf …` stores a Number (`f64::from_str` accepts `inf`, `infinity` and `nan`, case-insensitively), where a user writing the word almost certainly meant text.\n\nSo a text cell holding `100`, which `.improv` writes pipe-quoted precisely so it reads back as text (gotcha 34, `pipe_quote`), cannot be written from the command language at all. That makes it a clause-1 gap for ADR 006, and it blocks any canonical-line recording of a `SetCell` with such a value. The browser had the mirror-image bug on its edit path (improvise-ixxx, closed).\n\nReproduction:\n improvise cmd 'add-category Region' 'add-item Region East' 'add-item _Measure Code' 'set-cell \"100\" Region/East _Measure/Code' -f t.improv\n # Data: Region=East, _Measure=Code = 100 (a Number)\n\nFix direction: give the value a spelling that cannot read as a number. For example, a pipe-quoted value is always text, once improvise-31oe.3 lands, which matches `.improv`'s own rule. Decide `inf`/`nan` at the same time. Demonstrate with a failing unit test on `SetCellCmd` first.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:21:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:21:39Z","dependencies":[{"issue_id":"improvise-31oe.9","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:21:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.15","title":"Folder import: the merged Default (initial) view pages a category only some files have, so it opens empty","description":"Found in the delta review of PR #314 at c43ee724. It was already true at 4e68372c, and the round-1 review missed it. The merged Default view seats every category by the single-file rule, so a category only one file has can land on Page. Sparse records never match a page selection they lack (PR #314 Q1, pinned by a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid). Reproduced: improvise import \u003cdir\u003e --no-wizard with age.csv (entity,age-months; 2 rows) and coupling.csv (entity,coupled,degree; 1 row). Default then has \"_Measure: page\" (defaulting to age-months) and \"coupled: page\" (defaulting to src/b.rs). The age records lack coupled and coupling has no age-months, so the document opens on an empty grid. Related to improvise-gtn7.10 (records the view cannot place), but narrower: an import should not choose an initial view that hides most of its own records. One option: in a multi-source merge, Default puts a category not every source has on None rather than on Page/Row/Column.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:58:28Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:58:28Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.15","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:58:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.14","title":"Import silently stores nothing for a record with no measure value; a measureless CSV in a folder vanishes","description":"Found in the delta review of PR #314 at c43ee724. A record whose accepted measures are all absent writes no cell, and no warning says so. The categories and items it named are created, but the record is gone. The same thing held for a single-file import before #314, but folder import makes it easy to hit. Reproduced: improvise import \u003cdir\u003e --no-wizard, where dir holds age.csv (entity,age-months / src/a.rs,2) and tags.csv (entity,tag / e1,red). The output has items e1 and red and a records view for tags, but ## Data holds only the age line, and the tags view draws zero rows. The CLI prints only \"Saved to\". The test a_file_with_no_measure_gets_a_records_view_not_another_files_measure asserts row_count 0 (\"a record with no measure has no cell\"), which pins the loss rather than the intent. Decide: warn and count (as 5j9 did for skips), or store the record, e.g. as a Text cell or a presence marker. Records are sparse, and the user wants sparse records kept.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:58:03Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.14","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:58:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n28d.1","title":"Drill shows only informative columns: drop categories constant across the drilled records","description":"USER 2026-09-25: 'I think 1+2 make sense, reseting is fine'. When drilling a cell, the _Drill view's records columns include the categories the drill fixed (they are on Axis::Filter at one item, gotcha 27) -- constant across every drilled record, so they carry no information. Default drill columns: categories that vary among the drilled records, plus the measures the drilled cell shows (not every measure in the model). Drill columns RESET on each drill (the user: 'reseting is fine'); _Drill stays virtual and unpersisted. Builds on improvise-31oe.5 (records columns follow the view). Tests first: a drill fixes Region and Month, so neither is a column; a category that varies stays; a hidden measure is absent; re-drilling resets. Mind pending-edit addressing by (row, RecordCol) (gotcha 1): column indices change with the column set.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:57:25Z","labels":["drill","records","ux"],"dependencies":[{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-31oe.5","type":"blocks","created_at":"2026-09-25T04:57:32Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-n28d.1","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T04:57:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.10","title":"ADR 007 step 9: import generates ids by slugify and never parses them from headers","description":"ADR 007 answer 8 and clause 5, \"Import\". CSV and JSON dimension names are displays and are never parsed for ids. Every id comes from the workbook's IdAllocator, so two headers that slug alike get a gensym suffix. The saved file is the new version. Most of this falls out of step 3, when creation sites allocate ids. This bead is the import-specific evidence, plus whatever the wizard's preview or naming steps show.\n\nImport is other agents' ground (improvise-8jk6.3/.4/.5/.6 and improvise-lpzu), so coordinate and merge.\n\n## Acceptance Criteria\n- Tests first, through `improvise import --no-wizard`:\n - headers `Region(code)`, `Size(cm)` and `Time Period`/`Time_Period` import as displays with ids `regioncode`, `sizecm`, `time_period` and `time_period-\u003cn\u003e`;\n - the saved file pipe-quotes `|Region(code)|(regioncode)`;\n - the saved file reloads with every cell still drawn (improvise-80rm).\n- Rival: parse a trailing `(token)` as an id, and the first test fails.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:38Z","dependencies":[{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:38Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.10","depends_on_id":"improvise-cq1q.5","type":"blocks","created_at":"2026-09-25T04:51:03Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.9","title":"ADR 007 step 8: clause 12 — a formula edit captures its display-to-id mapping when the editor opens","description":"ADR 007 clause 12: a formula edit maps its display names to ids when the editor opens, following the OpenedCell pattern. It is blocked by the user question in improvise-hexb item 2 and improvise-m56i item 6: which side holds the mapping, what ReplaceFormula.raw carries on the wire, and how an ambiguous typed name resolves. The TUI and the browser must reach the same answer.\n\n## Acceptance Criteria\n- The required regression test, as corrected by improvise-hexb item 1:\n 1. Open the editor on `Profit = Revenue - Cost`.\n 2. From another client, rename `Revenue` to `Sales`, and rename a different item to `Revenue`.\n 3. Commit `Revenue - Cost * 1`.\n 4. The stored formula references the original item's id, and the commit is not refused.\n- Two rivals, each seen to fail: resolving at commit time (binds to the new Revenue), and refusing on rename.\n- A newly typed name that does not resolve is refused, and the refusal names it.\n- A captured id that is gone at commit gets Rejection::Gone.\n","notes":"Updated for the user's decision of 2026-09-25 (answer 32; PR #326): the CLIENT holds the capture, as it holds OpenedCell, and the resolver lives in improvise-protocol so the TUI and the browser share it. ReplaceFormula.raw and AddFormula.raw carry only id-form text, and the host never resolves a display name. It only parses ids, and one that is gone is Rejection::Gone. Typed names new to the edit resolve against the client's id-bearing projection, displays first and then ids (answer 31); an ambiguous display is refused with the candidates' ids listed. The regression test edits a non-name token (Revenue - Cost -\u003e Revenue - Cost * 1), per hexb item 1. The dependency on improvise-hexb is satisfied: it is answered.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:40Z","dependencies":[{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:26Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.6","type":"blocks","created_at":"2026-09-25T04:51:00Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:01Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.9","depends_on_id":"improvise-hexb","type":"blocks","created_at":"2026-09-25T04:51:02Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.8","title":"ADR 007 step 7: wire operands by id, create by display, Rejection::Gone, ids_named and broken_chain's id arm","description":"ADR 007 clauses 6 and 7: the wire vocabulary by id; migration step 6.\n- Every ModelCommand operand that names an existing thing is an id. A create op takes a display and its parent's id: AddItem { category: CategoryId, display }, AddItemInGroup, AddFormula { target_category, raw }.\n- CreateView { display, switch: true } replaces the [CreateView, SwitchView] compound.\n- RenameRecordCoordinate { record, category: CategoryId, item: ItemRef::{Existing(ItemId), New(display)} }, and the category in CellId::Coordinate becomes a CategoryId.\n- The older variants move to ids one whole variant at a time: SetAxis, SetPageSelection, HideItem, SetCell/ClearCell { view }, and the rest.\n- Rejection::Gone(Named) comes with an exhaustive ids_named feeding Checked::against, and an id arm in broken_chain (improvise-gkyo). Rejection loses Copy. Remove CheckedBatch's created-record exemption, which no longer has a producer (improvise-p003 item 5).\n- The host assigns every id.\n\nThis may be split per variant group if it is too wide for one PR.\n\n## Acceptance Criteria\n- Tests first:\n - [RemoveFormula(f), ReplaceFormula(f)] is refused whole;\n - a command naming a removed id gets Rejection::Gone with the id's text;\n - an AddItem racing a remove-and-re-add of its category lands nowhere (Gone), not on a namesake. The rival, a parent addressed by display, fails it;\n - create-and-switch-view is one command and switches to the id the host assigned.\n- vocabulary_is_complete and vocabulary_is_composed still hold.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:15Z","created_by":"agent-adr007","updated_at":"2026-09-25T04:50:15Z","dependencies":[{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:15Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T06:41:56Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.8","depends_on_id":"improvise-cq1q.7","type":"blocks","created_at":"2026-09-25T04:50:59Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.7","title":"ADR 007 step 6: projections carry id and display; ViewState keyed by id","description":"ADR 007 clause 6, projections and ViewState; migration step 5.\n- The projections carry id and display together:\n - CategoryRow { id, display, items: Vec\u003c(ItemHandle, String)\u003e, axis }, with ItemHandle::{Item(ItemId), FormulaTarget(FormulaId)} (improvise-i0n7);\n - FormulaCache rows { id, stored, display };\n - ViewSummary { id, display, .. }.\n- AxisFrame labels stay display strings.\n- ViewState keys by id: cursors, expanded_cats, view_back_stack/view_forward_stack, and active_view (improvise-p003).\n\n## Acceptance Criteria\n- Tests first: after a display rename of a category, that client's expanded_cats still holds the category, and its cursors and back history still name the view after a view rename. The rival, name keys, fails both.\n- The panel's formula row shows `Profit (profit) = Revenue - Cost` and shows ids where a display is ambiguous (answer 19). Use a fixture with two items displayed `Revenue` in different categories.\n- view_state_survives_a_json_round_trip still holds.\n","notes":"Reordered for improvise-cq1q.11 (agent-adr007, 2026-09-25). This step now lands BEFORE the re-key (cq1q.4) and depends only on cq1q.3. The projections gain an id field beside the display (CategoryRow, FormulaCache, ViewSummary). Clients compose every wire operand from that id field, and ViewState keys its cursors, expanded_cats, view history and active_view by it. Until cq1q.4 lands, id == the key == the name, so nothing changes behaviour. After cq1q.4 the key becomes the slug, and the browser's commands still name real keys. That closes the gap without an interim display-\u003eid resolver in model_effects. Added acceptance criterion: a test-only document whose key differs from its display (the cq1q.3 hook) composes SetAxis/HideItem from the panel with the KEY, and the host applies it. The rival, composing from the display, fails.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:50:03Z","created_by":"agent-adr007","updated_at":"2026-09-25T06:42:11Z","dependencies":[{"issue_id":"improvise-cq1q.7","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:50:03Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.7","depends_on_id":"improvise-cq1q.3","type":"blocks","created_at":"2026-09-25T06:41:46Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.6","title":"ADR 007 step 5: formula identity — FormulaId becomes one id, formula text over ids, lift kb1d","description":"ADR 007 clauses 1 and 5, and the km71 section: formula identity.\n- FormulaId's private fields become one id. Signatures do not change, which is why km71 made it opaque.\n- A formula's target display lives on the Formula. Where it is stored depends on the user's answer to improvise-m56i item 2.\n- Formula text is stored over ids (Formula::raw is no longer verbatim) and quoted through quote::quote_name (for example `|2024|` and `|sum|`).\n- WHERE/IS filter values are item ids in the stored text (the ADR's reading of answer 3; improvise-m56i item 5).\n- A target change becomes a display rename, so kb1d's refusal outside _Measure is lifted.\n\nBlocked by the user's answers to improvise-m56i items 2 and 6.\n\n## Acceptance Criteria\n- Tests first: renaming an item that a formula references does not change the formula's stored text or its FormulaId. The rival, a FormulaId still derived from (target, target_category), fails it.\n- Retargeting a formula outside _Measure succeeds. The test is the kb1d refusal inverted.\n- An id that begins with a digit or is a keyword round-trips through formula text.\n","notes":"Updated for the user's decisions of 2026-09-25 (answer 30; PR #326). This step implements: (1) FormulaId becomes one id, allocated separately from the target. (2) For a target outside _Measure, add_formula keeps creating the real item (its own ItemId), and the formula line names that target by the ITEM's id. For a _Measure target, Formula carries the target's display, and a formula cell's CellKey carries the FormulaId text under _measure. (3) Formula text is stored over ids, and a WHERE/IS filter value is an item id. The exact formula-line spelling waits for Q23 on PR #326; option (a) is recommended there. Added acceptance criteria: a formula targeting CustomCat item Tax has item id tax and a distinct formula id (tax-\u003cn\u003e). Renaming the item's display changes neither id, and the formula line still names tax.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","updated_at":"2026-09-25T18:59:39Z","dependencies":[{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:49:52Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:50:55Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-cq1q.6","depends_on_id":"improvise-m56i","type":"blocks","created_at":"2026-09-25T04:50:56Z","created_by":"agent-adr007","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-cq1q.1","title":"ADR 007: text fixes from the review tickets (no decision changes)","description":"Text-only fixes to context/adr/007-stable-identities.md. They clarify the ADR and change no decision the user made. This is an ADR PR, so it needs the user's explicit approval before it merges (AGENTS.md, \"ADR pull requests\").\n\nFold in:\n- improvise-le4p. In the new-version arm of view_entry, try axis_line (id_ref ~ \": \" ~ axis keyword) before format_line. The id `format` then reads as an axis line, and no number format is spelled as an axis keyword. id_ref needs no quoting arm. Required test: a category displayed `Format` on a row axis round-trips.\n- improvise-p003:\n 1. The virtual views get the reserved ids `_records` and `_drill`, following answer 23 (\"no non-virtual id can start with _\").\n 2. _Index/_Dim synthetic coordinates are record positions, not items. They are outside the id namespace and are never persisted. This is the ADR's own reading; flag it as such.\n 3. ViewState::active_view and Workbook::active_view hold a ViewId.\n 4. The collapsed set is (CategoryId, group name) while groups have no id (answer 9), and `collapsed:` writes the category id then the group name.\n 5. Under clause 6, CheckedBatch's created-record exemption has nothing left that produces it. It is removed in the wire step.\n- improvise-i0n7, what remains: on an ItemHandle::FormulaTarget row, remove means RemoveFormula, and rename means a display rename of the formula target.\n- improvise-hexb items 1, 3 and 4:\n - The regression test edits a non-name token (`Revenue - Cost` -\u003e `Revenue - Cost * 1`) instead of committing unedited text.\n - Clause 11 lists clause 12's capture step.\n - Fix the drift in \"the only reader is one test\" and \"on the idempotent path\" (this is also improvise-8vs2 item 4).\n - The kb.pl fact decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates the vacuous test. It must be replaced in the same change, and only the orchestrator writes kb.pl (scripts/kb replace).\n- improvise-8tk5, improvise-80rm, improvise-gkyo and improvise-rxz9 are already answered by revision 8 (answers 13 and 15; clauses 5, 6 and 7). Add one sentence on merges: two branches that add different things under one slug produce a duplicate id, and the merged file's load is refused (answers 11 and 29).\n\nOut of scope: improvise-m56i items 2 and 6, and improvise-hexb item 2. Those are user questions.\n\nAcceptance: `./scripts/bd-audit-ids` passes, no clause's decision changes, and the user approves on the PR.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr007","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","updated_at":"2026-09-25T17:52:26Z","started_at":"2026-09-25T17:52:26Z","lease_expires_at":"2026-09-25T17:57:26Z","heartbeat_at":"2026-09-25T17:52:26Z","dependencies":[{"issue_id":"improvise-cq1q.1","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T04:48:44Z","created_by":"agent-adr007","metadata":"{}"}],"comments":[{"id":"bb182e07-65db-5fa1-84f0-1473d61d7fa8","issue_id":"improvise-cq1q.1","author":"Edward Langley","text":"From the adversarial review of PR #318 (head 689480bf), which is Claude as an agent in the owner's session. Here are two readings that clause 4 leaves implicit and PR #318 had to choose. Consider flagging them in the ADR as its own readings, as p003 item 2 does.\n\n1. **\"Space\" means U+0020 only.** A tab, a no-break space (U+00A0) and an ideographic space (U+3000) are stripped, not turned into `_`. So `Time\\tPeriod` → `timeperiod`, which collides with `TimePeriod` rather than `Time Period`. The reasoning is in context/code/core.md §19. A tab-separated import header is the realistic case.\n2. **A slug made only of `-` is a legal bare id.** Displays `-` and `---` slug to `-` and `---`, and they are not treated as empty. The id_token alphabet admits them, and an `.improv` line such as `-: row`, or an item line beginning `- -`, is legal but unreadable. Step 5's \"if nothing is left\" treats only the empty string as empty.\n","created_at":"2026-09-25T06:19:59Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-nn4s.2","title":"Faces step 2: draw.rs face_style; axis_fg, minibuf_fg, field_kind_fg and mode_style become callers and disappear","description":"ADR 004 clause 8 step 2. One face_style(Face)-\u003eStyle in the TUI. AxisColor and Axis::color leave improvise-core (clause 6: face_of(Axis) in protocol). mode_style decomposes into the five mode faces plus the text-entry chain. Carries clause 8's one declared exception: minibuffer.category-add becomes green (user, Q1).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","updated_at":"2026-09-25T17:10:21Z","started_at":"2026-09-25T04:48:48Z","closed_at":"2026-09-25T17:10:21Z","close_reason":"Landed in PR #319 (merged 4ee5d4b1); post-merge review found the code sound (issuecomment-5836381716); gaps filed as nn4s.8-.12","dependencies":[{"issue_id":"improvise-nn4s.2","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:26Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.1","title":"Faces step 1: Face, Theme, resolve and the default theme in improvise-protocol","description":"ADR 004 clause 8 step 1. Face vocabulary as semantic data in improvise-protocol (clause 6), derived from domain enums where one exists (clause 2); Colour = Default|Named|Indexed|Rgb (clause 4); six portable attributes (clause 5); inheritance resolved once by Theme::resolve(Face) (clause 3); serde JSON shape with face names as keys (clause 9). The default theme contains no Rgb. Faces land only where a migrated call site needs them; later steps add the rest.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","updated_at":"2026-09-25T17:10:19Z","started_at":"2026-09-25T04:48:47Z","closed_at":"2026-09-25T17:10:19Z","close_reason":"Landed in PR #319 (merged 4ee5d4b1); post-merge review found the code sound (issuecomment-5836381716); gaps filed as nn4s.8-.12","dependencies":[{"issue_id":"improvise-nn4s.1","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:21Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.8","title":"create-view silently replaces an existing view of the same name and exits 0","description":"Found by the PR #313 review (issuecomment-5826870397): Workbook::create_view is an IndexMap::insert, so 'create-view X' when a view X exists overwrites it -- its axes, page selections, hidden items and collapse state -- with no message, and headless 'improvise cmd/script' exits 0. The repo-stats pipeline's interim create-view replaced a per-file view that folder import (#314) had built. Refuse (naming the view) unless an explicit replace is asked for; tests first in both the TUI path and headless. Related: improvise-31oe.2/31oe.3 (refusals for unknown names, in progress).","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:43:03Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.8","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:43:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.8","title":"Semi-additive measures: a different summary per category","description":"USER 2026-09-25, 'yes, file these'. One summary per measure cannot express a semi-additive measure: holdings value is SUMMED across Account but takes LAST across Date; a stock price is AVG or LAST across Date but meaningless summed across Ticker. Add per-category overrides to a measure's aggregation, e.g. in '## Aggregation': '- Value: sum; Date: last' (grammar first). Decide the fold semantics when several categories are folded at once (apply per-category functions in a defined order -- e.g. the overriding categories innermost -- since sum-of-last and last-of-sum differ), how a collapsed group of the overriding category uses it, and totals. Depends on the ordered last/first ticket for the canonical examples. Interacts with ADR 009's incremental aggregates.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:51Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.7","title":"Ordered summary functions: last and first (closing/opening value)","description":"USER 2026-09-25: 'we probably should also be able to specify a summary function. e.g. stock price doesn't make sense to add' -- and on the proposal: 'yes, file these'. Today a measure's summary is one of sum/avg/min/max/count (AggFunc, improvise-formula ast.rs; per measure in Model::measure_agg, '## Aggregation' section '- Price: avg'), applied to every fold incl. collapsed groups. A stock price wants LAST (closing) or FIRST (opening) over a period. Those need an ORDER over the folded values; the natural one is the item order of the category being folded (e.g. months within a year of a date hierarchy, improvise-8jk6.3/felo.6). Decide: which category orders the fold when several are folded at once (the time-like one? an explicit per-measure 'along' category?), ties, and what last/first mean when the ordering category is on an axis rather than folded. Grammar first (agg_kind in improv.pest). Interacts with ADR 009 (PR #309): its integrated per-group state is an unordered multiset -- last/first need ordered state; raise it there. Test with a stock-price fixture.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:50Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.7","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:30:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.3","title":"The command language does not read |pipe quoting|: create-view |By area| makes a view named |By, and extra arguments are silently ignored","description":"The `:` command language, used by `improvise cmd` and `improvise script`, does not read `|pipe quoting|`, though `.improv` files and formulas both use it for names (improvise_formula::quote). It also silently ignores extra arguments. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise cmd 'create-view |By area|' -f m.improv\n grep '## View' m.improv\n\nWhat happens: a view named `|By` is created, and `area|` is dropped without a word.\n\nWhat should happen:\n- A pipe-quoted argument is one argument with the pipes removed and the escapes applied. The rules are the ones `.improv` uses, and the code should come from the one shared home rather than a new copy.\n- A command given more arguments than it takes refuses with an argument error, and the headless run exits non-zero.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:11Z","started_at":"2026-09-25T04:33:11Z","lease_expires_at":"2026-09-25T04:38:11Z","heartbeat_at":"2026-09-25T04:33:11Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.3","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:56Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.2","title":"set-axis and set-page with an unknown category, axis or item are silent no-ops that exit 0 headless","description":"In `improvise cmd` and `improvise script`, `set-axis` and `set-page` with an unknown category, axis or item do nothing, print nothing and exit 0. A typo in a script therefore goes unnoticed. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on any model without a category called `Regoin`:\n\n improvise cmd 'set-axis Regoin row' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-page Region Nowhere' -f m.improv; echo \"exit=$?\"\n improvise cmd 'set-axis Region sideways' -f m.improv; echo \"exit=$?\"\n\nWhat happens: all three exit 0 with no message, and nothing changes.\n\nWhat should happen: each one refuses with a message that names what it could not find, and the headless run exits non-zero. This is the same path other refusals take since improvise-udt2 and improvise-j6x8. An unparseable axis name is an argument error. An unknown category or item is a refusal.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-cmdlang","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:10Z","started_at":"2026-09-25T04:33:10Z","lease_expires_at":"2026-09-25T04:38:10Z","heartbeat_at":"2026-09-25T04:33:10Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.2","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:55Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.6","title":"An import that skipped records, even all of them, exits 0 and saves an empty model","description":"An import that skipped records exits 0 and saves the model, even when it skipped every record and the model is empty. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nPR #314 (improvise-8jk6.2) removes one cause, the skip on a missing category value. Other skips remain. For example, a JSON array element that is not an object is skipped.\n\nRepro:\n\n echo '[1, 2, \"three\"]' \u003e r.json\n improvise import r.json --no-wizard -o r.improv; echo \"exit=$?\"\n\nWhat happens: the file is written with no data, and the exit status is 0.\n\nWhat should happen: the user must hear about skipped records. At minimum, print a count on stderr. An import that kept no records at all should exit non-zero rather than save an empty model as a success. Whether a partial skip is a warning or an error, and whether a flag makes it strict, is a decision for the fix.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:06Z","started_at":"2026-09-25T04:33:06Z","lease_expires_at":"2026-09-25T04:38:06Z","heartbeat_at":"2026-09-25T04:33:06Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.6","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:53Z","created_by":"agent-gapfile","metadata":"{}"}],"comments":[{"id":"4ba3a96b-9421-522a-b648-0299ee89fbba","issue_id":"improvise-8jk6.6","author":"Edward Langley","text":"USER DECISIONS 2026-09-25 (PR #321): (1) a folder of only header-only CSVs: 'Refuse (as #321 does)' -- an import that keeps no record is refused, folder or not; (2) partial skip: 'Add a --strict flag' -- default stays exit 0 with a counted warning, --strict makes any skip non-zero; (3) JSON 1e3 forced to Category: '1000.0' (keep serde_json's rendering, as #321 does).","created_at":"2026-09-25T06:20:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8jk6.5","title":"import --formula silently drops a formula it cannot parse and exits 0","description":"`improvise import --formula` silently drops a formula it cannot parse. The import still saves and exits 0, so a script cannot tell that the formula is missing. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n printf 'a,x\\nk,1\\n' \u003e f.csv\n improvise import f.csv --no-wizard --measure x --formula 'Y = x +' -o f.improv; echo \"exit=$?\"\n grep -A3 'Formulas' f.improv\n\nWhat happens: no formula is written, nothing is reported, and the exit status is 0.\n\nWhat should happen: a formula that does not parse is an error the user asked for by name. Report it with the parse error on stderr and exit non-zero, the same way a headless refusal exits non-zero since improvise-udt2 and improvise-j6x8. Whether to also refuse to write the output file is a decision for the fix. Refusing is the conservative default.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:05Z","started_at":"2026-09-25T04:33:05Z","lease_expires_at":"2026-09-25T04:38:05Z","heartbeat_at":"2026-09-25T04:33:05Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.5","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:52Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.4","title":"import --category still parses a forced category as a number: 00565796 becomes 565796, 220944e2 becomes 22094400.0","description":"A column the user forced to Category with `import --category` is still parsed as a number, so its text is lost. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1): commit `00565796` became item `565796`, and `220944e2` was read as scientific notation and became `22094400.0`.\n\nRepro:\n\n printf 'commit,lines\\n00565796,3\\n220944e2,5\\n' \u003e c.csv\n improvise import c.csv --no-wizard --category commit --measure lines -o c.improv\n grep -A2 'Category: commit' c.improv\n\nWhat happens: the items are `565796` and `22094400.0`.\n\nWhat should happen: forcing a field to Category means its values are item names, and item names are text. The items must be `00565796` and `220944e2`, verbatim. Numeric parsing is for measures, and should not run on a forced category.\n","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:33:00Z","started_at":"2026-09-25T04:33:00Z","lease_expires_at":"2026-09-25T04:38:00Z","heartbeat_at":"2026-09-25T04:33:00Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.4","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:29:50Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.3","title":"Import a date column as ONE hierarchical category (Year \u003e Quarter \u003e Month), crosstab via formulas","description":"USER 2026-09-25, verbatim: 'I think dates should usually be one category and we can use formulas to crosstab'. Today a date column becomes up to three FLAT categories (Date_Year '2024', Date_Month '2024-01', Date_Quarter '2024-Q1'; analyzer.rs:81-93), never hierarchical -- groups predate date extraction (eae00522 vs a73fe160) but the import has never created a group. Change the default: one category named after the column, whose items are the finest chosen component (e.g. months) grouped into quarters grouped into years ('\u003e Q1-2024[2024]'), so it costs one of the 24 category slots instead of three and collapses naturally. Crosstabs (year x month) are done with formulas, not by splitting the category -- verify the formula language can do that today (a WHERE/IS naming a GROUP rather than an item: does it cover the group's members?) and file the gap if not. Keep the flat split available as a wizard choice. Blocked by the group-parent ticket. Decide item naming (a month item must stay unique across years: '2024-01', not 'Jan'), and headless flag parity (--time / --extract).","status":"in_progress","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:01:29Z","started_at":"2026-09-25T05:01:29Z","lease_expires_at":"2026-09-25T05:06:29Z","heartbeat_at":"2026-09-25T05:01:29Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:03:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8jk6.3","depends_on_id":"improvise-felo.6","type":"blocks","created_at":"2026-09-25T04:03:03Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"5aff5014-0515-5078-8dc1-b5ef15cceeb3","issue_id":"improvise-8jk6.3","author":"Edward Langley","text":"Per the user (2026-09-25, on improvise-felo.6): hierarchies start closed unless a view opts in to a depth -- so an imported date category shows years folded by default.","created_at":"2026-09-25T04:04:41Z"}],"dependency_count":1,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-felo.6","title":"Honour group parents: nested groups within one category","description":"Group::parent (model/category.rs:34) is parsed ('\u003e Q1[2025]', improv.pest group_hierarchy), written (format.rs:210), round-tripped and hashed (app.rs:195), but NOTHING in layout, the grid frame, collapse or aggregation reads it -- so Q1 is never drawn inside 2025. Make the within-category hierarchy real: expand_category emits nested group runs (reusing the GroupHeader{level}/GroupEnd{level} machinery that already nests runs across categories, core.md 'Group runs nest'); AxisFrame/FrameGroup carry the depth so the TUI and the browser draw the same headers (design-principles section 5); collapsing a group at any level folds everything under it into one row whose value is the fold over all covered leaf items (evaluate_covering must expand a group to its leaves through child groups); z toggles the innermost group at the cursor; hidden items and prune_empty behave at every level. Decide: cycles and dangling parents in a file (refuse on load vs ignore), and whether an item may sit directly in a parent group alongside child groups. Tests first, including a 3-level Year\u003eQuarter\u003eMonth fixture. Prerequisite for importing dates as one hierarchical category. Interacts with ADR 008 (operator shape includes group membership).","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-hier","owner":"el-github@elangley.org","created_at":"2026-09-25T04:03:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:39:32Z","started_at":"2026-09-25T04:05:54Z","closed_at":"2026-09-25T06:39:32Z","close_reason":"Landed in PR #317; reviewed PASS at 44718e8a","labels":["browser","core","tui","view"],"dependencies":[{"issue_id":"improvise-felo.6","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T04:03:00Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ff4b12b2-f571-55e3-aa28-96e355d35faf","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think hierarchies should start closed unless we somehow opt into a specific one opened to a certain depth (view config maybe?)'. So: groups start COLLAPSED by default; a view opts in to opening a category's hierarchy to a given depth (e.g. a per-category depth in View, persisted as a new view line such as 'expand: Date 2'), with per-group toggles (z) on top. Today View::collapsed_groups (view/types.rs:40) records exceptions to an EXPANDED default and .improv writes 'collapsed:' lines -- this inverts the default, so decide: (a) state = depth + a set of per-group overrides in both directions (opened-beyond-depth, closed-within-depth); (b) migration of existing files, whose 'collapsed:' lines assume groups default open -- a file with groups and no 'collapsed:' line would suddenly render folded; options: keep old files' meaning by version (the file-version literal selects the regime, as ADR 007 does), or accept the change. The migration choice is the user's; ask on the PR. Also affects the import default (improvise-8jk6.3: an imported date hierarchy opens closed).","created_at":"2026-09-25T04:04:39Z"},{"id":"72c7ea45-210e-576a-b25a-448b7a41b26b","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think, in most cases, the closed by default start is preferable because it makes the grid more dense.' (The reason for the closed default.) Bears on the migration question: it argues for old files also opening closed rather than version-gating the old open default -- pending the user's explicit answer.","created_at":"2026-09-25T04:06:05Z"},{"id":"29921739-1f03-5c2d-b0f7-9636a94fd0bf","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER DECISION 2026-09-25, verbatim: 'Everything opens closed, including old files.' No version gating: old files open closed too; their 'collapsed:' lines agree with the default.","created_at":"2026-09-25T04:06:39Z"},{"id":"73d76560-7ebf-52f8-bb02-ab7a856c2041","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'Yes, and we can add an expanded: line if it's not in the grammar. Since it's an additive change (and there's no real users), I'm not too concerned about strict compatibility so no version bump needed.' So: per-group opens persist as an 'expanded:' view line (mirroring 'collapsed:'), added to improv.pest; no file-version bump.","created_at":"2026-09-25T04:17:52Z"},{"id":"7a483598-1078-5d71-ba40-93444af3b91b","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think we'll need a depth for expanded, something like expanded(2): ...'. So the expanded line carries an optional depth: 'expanded(N): \u003ctarget\u003e'. Proposed semantics for the implementer to confirm in the PR: target 'Cat' opens the category's top N levels; target 'Cat/Group' opens that group and N-1 levels beneath it; bare 'expanded:' means N=1; collapsed: and z still override individual groups.","created_at":"2026-09-25T04:20:23Z"},{"id":"93ce9859-0231-52b7-a781-86560834865e","issue_id":"improvise-felo.6","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'expanded+collapsed in the same group is an error'. A view naming the same Cat/Group in both an expanded(...) line and a collapsed line is refused on load (naming it). Only the SAME group is an error; a collapsed group inside a region opened by an ancestor's or the category's expanded(N) is a legitimate override.","created_at":"2026-09-25T04:22:51Z"}],"dependency_count":0,"dependent_count":1,"comment_count":6} +{"_type":"issue","id":"improvise-31oe.1","title":"Auto-complete system (commands first)","description":"USER 2026-09-25, verbatim: 'We need a bead for an auto-complete system: commands are highest priority'. One completion engine in the shared layer (design-principles section 5: a front end displays state, it does not reimplement behaviour), used by the TUI and the browser alike: given a prompt kind and the text so far, return candidates. Sources, in priority order: (1) command names on the ':' command line -- from the registry, whose names come off prototypes (gotcha 15), never a hand-kept list; (2) command arguments -- mode names (parse_mode_name), panel names, axis keywords, category/item/view names from the model, file paths via Storage::list (not std::fs; the browser host must reuse it); (3) name completion in the text-entry minibuffers (category-add, item-add, formula edit refs with pipe quoting). Open design questions for the children: where the engine lives (improvise-command vs improvise-protocol, given the browser needs it and improvise-y11g puts the ':' line in the browser); key binding (Tab conflicts?); cycling vs a candidate list (a which-key-style popup exists in src/ui/which_key.rs); how candidates reach a thin client (projection vs client-side from a shared table).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T03:41:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:41:47Z","labels":["command","protocol","ux"],"dependencies":[{"issue_id":"improvise-31oe.1","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T03:41:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.5.1","title":"scripts/repo-stats: monitor churn by area, prose-to-code ratio, doc growth and change coupling","description":"USER 2026-09-25: 'add some tooling to monitor these stats' -- the stats the user ran by hand with code-maat on 2026-09-25 (coupling, age, abs-churn) plus the orchestrator's area split (since 09-19 on gh/main: Rust src +57k/-24k, context/test-evidence +25k/-1.4k, context/code +18k/-1.5k, site +5.6k/-0). Build a repo-owned script (no java/code-maat dependency; python or the repo's existing script language, run under nix develop) that reports over a window (default: last 7 days, on a named ref, default gh/main, no-merges): (1) added/deleted lines by area (rust src, rust tests, context/code, context/test-evidence, other context, repo-map, kb.pl, agent docs/skills, scripts/CI, web+harness, generated); (2) the prose-to-code ratio and each area's deletion ratio; (3) current size of the watched docs (lines of each context/*.md, repo-map gotcha count and longest gotcha, test-evidence entry count); (4) top-N change-coupled file pairs (co-change degree and revisions, code-maat's definition, min-revs threshold), flagging pairs that cross crate boundaries and pairs among the agent-rule docs (a rule stated in several places). Output: human-readable text plus --json. Add a CI step that runs it on pushes to main and writes the report to GITHUB_STEP_SUMMARY -- report only, never fails the build (budgets are a separate decision under the compaction epic). Give it a test in the repo's scripts/*-test.sh style against a fixture repo, wired into checkers.sh. Document it in context/code/tooling.md.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-stats","owner":"el-github@elangley.org","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:22:03Z","started_at":"2026-09-25T03:22:03Z","lease_expires_at":"2026-09-25T03:27:03Z","heartbeat_at":"2026-09-25T03:22:03Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.1","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:21:11Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"84080845-4c53-5f4a-8ecf-2c5c468f5815","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25 pointed at https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar -- so USE code-maat for the analyses it provides (coupling, age, abs-churn, soc, etc.) rather than reimplementing them. Pin the jar in nix (fetchurl with its hash) and add a JRE to the dev and ci shells; the script prepares the git log in code-maat's git2 format itself (scoped to a ref, --no-merges, a window) and adds only what code-maat does not do: the by-area split, prose-to-code and deletion ratios, doc sizes, and flags on the coupling rows (crosses a crate boundary; agent-rule docs).","created_at":"2026-09-25T03:21:25Z"},{"id":"11ba9c08-a926-5fec-8589-3c873ba4a4b9","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stat tooling could use improvise as its scripting engine.' So: churn/ratio analysis becomes an .improv model built headlessly (improvise import --no-wizard + cmd/script for formulas and views), with the .improv file as the artifact; code-maat stays for coupling/age/soc. Gaps in improvise's headless surface found along the way are findings to file, not silent workarounds.","created_at":"2026-09-25T03:28:35Z"},{"id":"41996941-7d1c-5bb6-a00d-4412929a1734","issue_id":"improvise-1xfb.5.1","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'code-maat generates CSV files in a directory that we process with improvise and use this to notice features and regressions at the command/model layer (which is its own first-class product, separate from the UIs)'. Depends on folder import (improvise-lpzu).","created_at":"2026-09-25T03:30:03Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} +{"_type":"issue","id":"improvise-1xfb.5","title":"Doc compaction: context/ states current rules, history lives in git","description":"USER 2026-09-25: 'doc compaction is a good idea'. Since 2026-09-19 context/code + context/test-evidence grew +42k/-2.8k lines (about 0.7 lines of prose per line of Rust source added) and almost never shrink; the repo-map gotchas run to pages and many narrate their own corrections ('this paragraph said X until...'). Every change pays to read and patch them, and today's reviews found stale copies each time a rule was stated in several places (code-maat: agent-rules/AGENTS.md/wave SKILL/code-review SKILL change together 39-48%). Goal: each doc states the CURRENT rule and its reason once; superseded narration goes (git and the ticket keep it); a rule stated in several files gets one home and pointers. Keep test-evidence entries keyed by test name (check-comment-runs --check-evidence) but compact them to what broke, the injected fault, and why the test can fail. Consider a size budget enforced by a checker so growth does not return. Sequence per area to avoid conflicting with in-flight PRs; repo-map.md is orchestrator-only.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T03:20:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:20:21Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:20:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.4","title":"Stop tracking site/: it is web/assemble.sh output","description":"USER 2026-09-25: 'remove site/' (and 'keep TAGS for now'). site/ is the directory web/assemble.sh assembles (landing page + scripts/build-doc-site.mjs output); 27 generated files are tracked on gh/main, grew +5.6k/-9 lines since 09-19, and drift stale (the category-cap review found site/docs/** still saying 12). Remove site/ from the index (git rm -r --cached), add it to .gitignore, and confirm CI's Pages job builds it from source rather than reading the tracked copy; check nothing (harness specs, pages.spec.js, landing.spec.js, doc-site tests, release scripts) reads the committed site/. Do not touch TAGS.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-1xfb4","owner":"el-github@elangley.org","created_at":"2026-09-25T03:20:19Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:52:30Z","started_at":"2026-09-25T03:20:45Z","closed_at":"2026-09-25T03:52:30Z","close_reason":"Landed in PR #312; reviewed PASS at 67634767","labels":["tooling"],"dependencies":[{"issue_id":"improvise-1xfb.4","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:20:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.5","title":"An acyclic formula chain deeper than MAX_EVAL_DEPTH evaluates to Error(\"circular\")","description":"Found by the ADR 009 agent (PR #309, question 4), 2026-09-25: recompute_formulas iterates the fixed point a bounded number of passes (MAX_EVAL_DEPTH), and a chain of 16 formulas each depending on the previous one -- no cycle at all -- has not stabilized when the bound is hit, so it reads Error(\"circular\"). Gotcha 9 and core.md section 3 describe the bound as the circular-reference detector; that conflates 'deep' with 'cyclic'. A topological evaluator (ADR 009) would give the right answer and disagree with eager, so the differential test would fail on correct code. Tests first: a 17-link acyclic chain evaluates to its value; a 2-cycle still gives Error(\"circular\"). Fix shape to decide: detect cycles in the formula dependency graph and bound only those, or evaluate acyclic formulas in dependency order.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:19:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:19:21Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-felo.5","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:19:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.3","title":"Agents set the git author name to include their BEADS_ACTOR","description":"USER, 2026-09-25, verbatim: 'agents should override git author name, I think to include an agent identifier (matching BEADS_ACTOR)'. Today every agent commit is authored as the repository owner, so history cannot say which agent wrote what. Proposed default (confirm on the PR): author name 'Claude (\u003cBEADS_ACTOR\u003e)', author email unchanged (the owner's configured email, so GitHub still links it), committer untouched; the Co-Authored-By trailer stays. Mechanism must survive a shell that does not persist env between commands and must NOT change shared git config: worktrees share .git/config, so 'git config user.name' in one worktree renames every agent. Candidates: 'git -c user.name=... commit' per commit, GIT_AUTHOR_NAME per command, or a tiny scripts/agent-commit wrapper reading BEADS_ACTOR (refusing when unset). Put the rule in .agents/skills/wave/agent-rules.md next to 'Claim first: export BEADS_ACTOR', and in the brief checklist in wave/SKILL.md. Related: improvise-xfsb (agents posting to GitHub under their own identity, deferred).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-1xfb3","owner":"el-github@elangley.org","created_at":"2026-09-25T03:15:45Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:54:49Z","started_at":"2026-09-25T03:16:08Z","closed_at":"2026-09-25T03:54:49Z","close_reason":"Landed in PR #310; reviewed PASS at 0539fb37","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.3","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:15:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.4","title":"ADR 009: incremental evaluation — an in-house DBSP-core crate, pluggable","description":"USER DECISION 2026-09-25, verbatim: 'I think doing our own implementation of the core of dbsp as a crate we can plugin is probably dieal [sic] here. Write the ADR and Update ADR 008 to assume the decisions in that one'. Prompted by the user pointing at https://github.com/brurucy/dbsp-from-scratch (a teaching implementation of the DBSP paper: Z-sets, streams, delay/differentiate/integrate, lifted/incremental join, distinct, recursion by nested streams) as a way to evaluate formulas so data can stream. Scope: an ADR (next free number, 009) specifying a workspace crate implementing the DBSP core (not depending on Feldera's dbsp), and how improvise plugs it in: cell writes as Z-set deltas; incremental group-by aggregates (SUM/COUNT linear, AVG derived, MIN/MAX and the text-unanimity rule via integrated per-group multisets); per-row occupancy counts for prune_empty; formula dependencies updated in topological order with the circular check kept (DBSP's least-fixed-point recursion is not our circular-error semantics); f64 non-associativity vs ADR 005 exact decimals (improvise-l2b8); ADR 003 live-backed sources as delta streams; wasm32 cleanliness. Then ADR 008 (PR #308) is revised to assume ADR 009's decisions (notably its question 6, totals).","status":"in_progress","priority":2,"issue_type":"decision","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:04:04Z","started_at":"2026-09-25T03:04:04Z","lease_expires_at":"2026-09-25T03:09:04Z","heartbeat_at":"2026-09-25T03:04:04Z","labels":["adr","core","formula"],"dependencies":[{"issue_id":"improvise-felo.4","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T03:03:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.3","title":"Virtualize the grid: no limit on grid size, realize only what is visible","description":"layout.rs:935 cross_product and layout.rs:690 prune_empty matrix. The category cap never bounded either; a user who puts many categories on Row or Column builds an arbitrarily large grid. Decide whether a guard (refuse, warn, or lazy layout) is wanted. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"felo3-adr","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:33:29Z","started_at":"2026-09-25T02:33:29Z","lease_expires_at":"2026-09-25T02:38:29Z","heartbeat_at":"2026-09-25T02:33:29Z","labels":["core","perf"],"dependencies":[{"issue_id":"improvise-felo.3","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:30:30Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"2ba6da4a-78fa-593e-84f9-6372c7f1afe8","issue_id":"improvise-felo.3","author":"Edward Langley","text":"USER DIRECTION 2026-09-25, verbatim: 'Ideally, there should be no limit on the size of the grid, we should just virtualize it so we only have to realize what's visible.' So the answer to 'refuse, warn, or lazy layout' is: lazy -- no guard, no size limit. Today GridLayout eagerly builds cross_product per axis (layout.rs:935) and prune_empty builds a rows x cols matrix (layout.rs:690). Known hard parts to design, not assume away: (1) prune_empty needs to know whether a row/column is empty across the WHOLE grid, not just the window; (2) column totals are sums over every row, including ones never scrolled to (gotcha 44 -- must stay projected, not client-derived); (3) row/column COUNT and index-\u003etuple mapping without materializing (mixed-radix indexing over item lists works when nothing is pruned or collapsed; groups, collapse and hidden items break pure arithmetic); (4) records mode rows (see improvise-sk32); (5) GridCache is already viewport-indexed for the browser, so the protocol side may need little change. Likely warrants an ADR.","created_at":"2026-09-25T02:31:59Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-8jk6.1","title":"Import wizard field list does not scroll, so the over-cap case cannot show every field it asks the user to fix","description":"import_wizard_ui.rs:136 stops at the popup bottom (popup at most 30 rows, line 39), unlike the steps that use the scrolling list PR #304 added. About 22 fields fit; a CSV with more columns hides fields, including the ones the over-cap precondition tells the user to Skip. Use the same scrolling/Panel machinery. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:28Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:28Z","labels":["import","tui","ux"],"dependencies":[{"issue_id":"improvise-8jk6.1","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T02:30:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.8","title":"TUI side panels do not scroll: items past the panel height are undrawn and the cursor can move onto them","description":"src/ui/panel.rs:77-81: the category/formula/view panels draw from the top and stop at the panel height; the cursor keeps moving into undrawn rows. The category panel lists every category plus expanded items, so 24 categories overflow a normal terminal. Scroll to keep the cursor visible, with more-above/below markers; test at a small height. Related: improvise-n8vh (long names overflow the border), improvise-j7ij. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:27Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.8","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.7","title":"TUI page bar is one unclipped line: with many Page categories most selectors are cut off with no marker","description":"grid.rs:452-465 draws the page bar as a single line; default seating (view/types.rs:127) puts every category after the second on Page, so a 24-category import shows 22 page selectors on one line, clips most silently, and opens on one slice. Wrap or scroll it (user rule messages-wrap-not-truncate applies to anything the user must read), with a visible marker. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:26Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:26Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.7","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:26Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"aa6e2320-d872-5de9-b1fa-97727d3c7e0b","issue_id":"improvise-gtn7.7","author":"Edward Langley","text":"Also (PR #307 review): the page bar's set_string is not clipped to inner.width, so it can overwrite the right border too.","created_at":"2026-09-25T02:40:25Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-gtn7.6","title":"TUI visible_rows ignores column header levels, the group row and the page bar, so the cursor scrolls below the drawn grid","description":"app.rs:969 sets visible_rows = term_height - 8, a fixed chrome allowance; grid.rs:128 draws one line per column level. With several categories on Column the vertical scroll counts rows that are never drawn -- the vertical twin of improvise-gtn7.1 (a scroll must be measured where it is drawn). Derive the grid height from one function the draw also uses; test: with N column levels, moving down keeps the selected row drawn. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:25Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.6","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.5","title":"TUI row header grows with every Row category until the data area is zero","description":"src/ui/grid.rs:94,102 and ui/viewport.rs:154-161: the row header is the SUM of per-level widths (4..24 cells each), so enough categories on Row shrink the data area to nothing. Needs a cap/compaction rule for the header and a test that some data column always draws. Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:23Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.5","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T02:30:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.2","title":"agent-rules: default to a new conventional commit; amend HEAD only for small unpushed fixes","description":"USER, 2026-09-25, verbatim: 'As a general rule, you shuold just add a new conventional commit. Small adjustments can ammend HEAD, but when in doubt don't.' PR #305 (merged 6427312d) wrote rule 8 and 'Commit early' as allowing an agent to 'amend, reorder or fold' unpushed commits and to 'tidy WIPs before the first push'. That is broader than the user now states: reordering and folding are out; the default is a new commit; amending is limited to HEAD, for a small adjustment, and only while unpushed; when in doubt, a new commit. Also: commit messages follow Conventional Commits (type(scope): subject) -- the recent history is mixed (docs:/ci:/chore:/test: alongside 'improvise-7mro: ...' and 'pr-reviews: ...'), so state the convention and how a ticket id is carried (e.g. in the scope or a trailer). Update agent-rules rule 8 + 'Commit early', AGENTS.md's top line, context/cloud-agents.md, the code-review skill summary line.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T02:19:18Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:45:04Z","started_at":"2026-09-25T02:19:41Z","closed_at":"2026-09-25T02:45:04Z","close_reason":"Landed in PR #306; reviewed PASS at 94260d58, Codex P2/P3 answered","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.2","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T02:19:18Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sk32","title":"Records-mode layout build is quadratic in records: RecordRow::group scans rows linearly per cell","description":"Found while measuring improvise-felo.1 (crates/improvise-core/examples/category_width_bench.rs, Apple M4 Max, release). GridLayout::with_frozen_records in records mode: 2,500 records 79-82 ms, 5,000 records 302-321 ms, i.e. ~4x for 2x records, at BOTH 12 and 24 categories (width barely moves it). Cause: view/layout.rs RecordRow::group finds a cell's row with rows.iter().position(|r| r.coords == coords) -- a linear scan per cell, so O(cells x records). Drawing the built layout is linear (3 ms / 7 ms). Fix shape: index rows by coords (HashMap\u003cCellKey, usize\u003e) while keeping first-appearance order, which gotcha 18 / core.md section 8 require; a test must pin the order is unchanged. Not caused or worsened by the category cap.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:14:46Z","created_by":"wave-felo1","updated_at":"2026-09-25T02:14:46Z","labels":["core"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.1","title":"agent-rules: no squash, rebase or reset — reconcile diverging history only by merge","description":"USER RULE 2026-09-25, verbatim: 'generally there should be no squashing or rebasing in git, just merge to reconcile diverging histories.' Prompted by the improvise-cgqt agent squashing with 'git reset --soft gh/main' after #303 merged mid-session, which staged a commit that reverted #303 (caught by numstat before push) -- the same accident as improvise-diho/#196. Rule 7 of .agents/skills/wave/agent-rules.md currently treats 'reset' as a normal operation and only pins the SHA; the fix is to forbid the operation, not to make it safer. Change agent-rules (and AGENTS.md where it restates git policy): no git rebase, no squash (reset --soft, merge --squash, rebase -i fixup), no amending or force-pushing a pushed commit; take newer main with 'git merge \u003cpinned gh/main SHA\u003e'; WIP commits stay as they are. Keep rule 7's SHA pinning for merge-base and diff. Review the recovery recipes in AGENTS.md that use cherry-pick (a PR built on the wrong base; a fix pushed after merge) and say whether they stay as the one sanctioned exception or become merges. Also: bound any test loop that walks a state machine until a step.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-25T01:47:05Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:15:34Z","started_at":"2026-09-25T01:48:18Z","closed_at":"2026-09-25T02:15:34Z","close_reason":"Landed in PR #305 (merge 6427312d), reviewed PASS at 9b9b1efa","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.1","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:47:05Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"8347c6b5-9936-5254-83d7-d4af2f05b388","issue_id":"improvise-1xfb.1","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'Git operations that preserve history should generally be preserved, because the merge structure of git history is a causal record.' This is the rule's reason; state it in agent-rules. It bears on the cherry-pick recipes: a cherry-pick loses the causal link, so prefer a merge wherever one does the job.","created_at":"2026-09-25T01:49:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-gtn7.3","title":"The TUI status line truncates import warnings (and every long status) to one row","description":"Per the user's rule (bd memory messages-wrap-not-truncate, 2026-09-25): a message the user needs to read wraps, never truncates. AcceptWizardModel and ImportJsonHeadless join every import warning into status_msg ('Imported with warnings: a; b; c'), and draw.rs::draw_status / fill_line cut the status line to one row -- the same truncation PR #304 fixed inside the wizard, one screen later. Any long refusal/error on the status line has the same problem. Shape to decide: a status area that grows to fit (bounded, with a way to see the rest), or long messages routed to a readable pane. Test: a status message wider than the terminal renders every word. Found by the improvise-cgqt agent.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:46:56Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:46:56Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.3","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:46:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.2","title":"TUI grid cursor scrolls off screen across columns of mixed width","description":"Found while fixing improvise-gtn7.1. With NO side panel open, a row of ten narrow columns followed by wide ones: moving right from column 9 selects columns 10..14 while the grid draws none of them (col_offset 1,3,6,9,12). Cause: App::cmd_context computes visible_cols once, at the OLD col_offset, and navigation.rs::viewport_effects then applies that count at the NEW offset (nc - (vc-1)); seven narrow columns fit from offset 0, but not seven wide ones from offset 1. A single count cannot be right across mixed widths. Fix shape: the new col_offset must be the least offset from which columns offset..=nc fit (widths + data width), the same shape as the tile bar's first_tile. Constraint: improvise-protocol resolve.rs::scrolled_to mirrors viewport_effects line for line over a column-COUNT window and tests/lockstep.rs holds host and client in agreement, so a width-aware host scroll must decide what the browser's window means first. Probe: draw.rs content_tests shape, 1 row x 16 cols (A0..A9, then 28-wide names), 80x24, panels closed.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:28:54Z","created_by":"agent-gtn7-1","updated_at":"2026-09-25T01:28:54Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.2","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:28:54Z","created_by":"agent-gtn7-1","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb","title":"Epic: CI, checkers and repository tooling","description":"The repository's own machinery: scripts/ checkers and their check_the_checker (7k7v, 4zei, gc9t, rv02, xebx, e958, yqnp, z4up, sxsf, mszy), GitHub workflows and the Pages deploy (864a), PR-watching scripts (qvpu, weoe), dev-shell completeness (q36k), bd setup blocks (bf0q), the doc site build (56qu), and agent identity on GitHub (xfsb). Out of scope: the release workflow and its preflight (improvise-0s6), and test gaps inside a product area, which belong to that area's epic.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:15Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fhj0","title":"Epic: Browser page shell — web/, DOM, CSS and bundle","description":"Everything the browser page itself decides: web/app.js, shell.js, style.css, index.html and the wasm bundle's build flags. Covers banner, minibuffer and contrast polish (0w7y, 6acs), sticky headers and phone-width layout (8mgz, mv1d, rx8g), DOM event delegation and click surfaces (b8ot, j79v), Ctrl-S and the browser's own shortcuts (clso), history and URLs (dsn2), the page bar (ozxr), render parity (pcje, t3oh), and the handshake watchdog (a3dj, 80vh). Out of scope: key resolution and ClientState in Rust (improvise-6jk), and the worker, OPFS and multi-tab (improvise-tm6).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:15Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7","title":"Epic: TUI and shared front-end UX","description":"What the user sees and presses in the terminal UI, plus UX decisions both front ends share: help overlay layout (j4i9, swsi, trd3), panel rendering (n8vh, 1dzx), panel features such as rename (fz77), the keymap structure (636d global keymap), ZZ routing (rw3a), glyphs and faces as UI-agnostic data (h04z, nn4s ADR 004). Out of scope: browser DOM and CSS (the browser page-shell epic), browser key parity (improvise-6jk), and restructuring the TUI as an in-process client (improvise-ltq).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:14Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6","title":"Epic: Import, export and .improv persistence","description":"improvise-io's import pipeline and wizard (1uqj, bqsl, cgqt, lpzu), export formats (Parquet a5q), and .improv writer fidelity (msg2). Out of scope: the id grammar in .improv (the ADR 007 epic), SQLite as a source and SQLite export (improvise-rucf), and Storage/OPFS backends (improvise-tm6).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:13Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n28d","title":"Epic: Records mode and drill","description":"Wide records mode and drill-down, on both front ends where the defect is in the shared logic: drill commit validation against concurrent removals (5mqm, ddp6, 6r2y, o9q7), records renames that overwrite an existing record (kndq), yank and paste inside records and drill (48jd, eucp, s1lx), the saved records-layout mode (gj5p), the one shared _Drill view (t219), and drill previews (zavn). Out of scope: browser key resolution onto records columns (improvise-6jk), and aggregate addressing across views (the wire epic).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:12Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe","title":"Epic: Command layer — everything is a command (ADR 006), Cmd/Effect hygiene and the CLI","description":"improvise-command's Cmd/Effect layer and the headless CLI: ADR 006's everything-is-a-command and replay (ilvl), with undo/replay (m91) and transactional Sequence bindings (0hb); CmdContext shape (2ud); pub items with no production caller (a1ec); the refuse/abort bookkeeping and its guards (lasd, bj61, y8ym); command tests that do not discriminate (3m7b, 9trl); headless-script behaviour (ep5o, cz3o); and stale prose in context/code/command.md and cli.md. Out of scope: wire vocabulary (the wire epic), records and drill commands (the records epic), and TUI key and UX decisions (the TUI epic).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5krd","title":"Epic: Concurrent edits — ADR 001 layers 1 and 2","description":"The rest of context/adr/001-concurrent-edits.md: keeping a rejected batch staged and letting force re-compose it (3al1), showing what changed when an intent cannot be re-composed (vb6l), notifying the overwritten client (2wi8), advisory reservations (layer 2, 7b8v), moving Basis onto the frame (is8c), and basis-check defects such as 4v9f. Out of scope: wire commands that are missing, and projection or cursor bookkeeping (the wire-and-projection epic), and records-mode rename collisions (the records and drill epic).","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:10Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:10Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tr08","title":"Epic: Security of the hosts and the wire","description":"Holds work that closes a way for an untrusted peer or page to read, write or crash something it should not: path traversal through LoadModel (owep), the ws-server's missing authentication (kpnr), and wire input that can panic or overflow the host or client (0acd). Out of scope: origin checking that has already shipped (kc7n), and preparing the public mirror, which stays under the distribution epic improvise-0s6.","status":"open","priority":2,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:07Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bqsl","title":":import in the TUI parses every file as JSON, so a CSV fails with a JSON parse error","description":"StartImportWizard (crates/improvise-command/src/ui/effect.rs, impl Effect for StartImportWizard) reads the path through app.storage and hands the bytes straight to serde_json::from_slice, so ':import sales.csv' refuses with 'JSON parse error' although the CLI's 'improvise import sales.csv' handles CSV via csv_parser. Two front doors, two answers to 'what can be imported' (design-principles §5: a derivation done twice). Fix: one shared 'bytes + file name -\u003e import records' function in improvise-io (CSV by extension, else JSON) that both the CLI's get_import_data and StartImportWizard call; csv_parser already has _bytes/_from variants over Storage (gotcha 2). Demonstrate first with a failing test: StartImportWizard over a MemoryFs holding a small CSV must open the wizard, not refuse. Consider whether :import should also take several paths (merge_csvs) — at least note the gap if not done.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T00:50:39Z","labels":["import"],"dependencies":[{"issue_id":"improvise-bqsl","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lpzu","title":"improvise import should accept a folder of CSV files","description":"Today 'improvise import data/*.csv' works only because the shell expands the glob: get_import_data (src/main.rs) merges several CSV paths via csv_parser::merge_csvs, which adds a 'File' field per row from the file stem. Passing the folder itself ('improvise import data/') fails, because a single path goes to read_to_string, which errors on a directory. Wanted: a directory argument expands to its *.csv entries in a DETERMINISTIC order (sorted by name, not read_dir order — see gotcha 18, the same CSV imported twice must give the same .improv), non-recursive, then goes down the existing merge path. Decide and test: an empty folder (refuse, naming the folder), a folder containing non-CSV files (skip them or refuse — state which), and a folder mixed with file arguments. The directory listing belongs behind Storage::list so the browser/OPFS host can reuse it (gotcha 2), not std::fs in main.rs.","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T00:50:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:19Z","started_at":"2026-09-25T03:32:32Z","closed_at":"2026-09-25T05:02:19Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-lpzu","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:28Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"ba36392a-bcb8-5db3-b276-13dc05ca2451","issue_id":"improvise-lpzu","author":"Edward Langley","text":"2026-09-25: now needed by improvise-1xfb.5.1 -- the user's design is that code-maat writes its CSVs into a directory and improvise processes that directory. Note code-maat's outputs have DIFFERENT schemas per analysis (coupling: entity,coupled,degree,average-revs; age: entity,age-months; abs-churn: date,added,deleted,commits), so folder import must decide what heterogeneous headers mean (one merged model with a File category and sparse measures, vs refuse, vs one model per schema). Raised P3 -\u003e P2.","created_at":"2026-09-25T03:30:03Z"},{"id":"3aafd79b-1988-54fc-860d-318579349b5a","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER DECISION 2026-09-25, verbatim: 'Records are already sparse: the model can just leave null/empty values for columns not in a given CSV file.' So heterogeneous headers MERGE into one model (with the existing File category from merge_csvs), and a record simply has no value for a column its file lacks -- not refuse, not one model per schema.","created_at":"2026-09-25T03:32:18Z"},{"id":"91707f9f-2542-533b-b5d9-41d0980ab506","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'The stats model should pre-create views per file (also dir import should do this: one view filtered by the file column and projected to the columns in that file)'. So folder import creates, per source file, a view named after the file: File fixed to that file (filtered), and only that file's own columns shown -- its categories on the grid axes, its measures visible, and other files' categories/measures out of the grid (categories on None, other measures hidden). The merged default view stays.","created_at":"2026-09-25T03:38:09Z"},{"id":"1a0b8382-9330-5788-b62e-6b41c886a881","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25: (1) '(none)' position: 'I think leaving it out for now is fine, maybe a UI element somewhere that shows the number of records underneath the current view?' -\u003e filed separately. (2) single-CSV folder: 'yes, we might want to have some kind of update import eventually (don't plan this yet, but we'd want file identity for this)' -\u003e a folder with ONE CSV still gets the File category and its per-file view.","created_at":"2026-09-25T04:30:53Z"},{"id":"b586e154-47ae-513c-9312-a04eef63ffaa","issue_id":"improvise-lpzu","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'yeah, adding a single file shouldn't get the File category'. Confirms the PR #314 rule: a folder (even of one CSV) always merges with File; a lone CSV named directly imports alone, without File.","created_at":"2026-09-25T04:54:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":5} +{"_type":"issue","id":"improvise-weoe","title":"pr-reviews --wait cannot see a plain PR comment, so it never wakes for the user's ADR approval","description":"scripts/pr-reviews --wait is satisfied only by a review or an inline comment whose commit is the current head (has_head_review). A thread comment (issues/{n}/comments) is attached to no commit, so by design it can never satisfy --wait. However, ADR approval arrives as exactly that: the user comments 'Approved' / answers questions on the thread (AGENTS.md 'ADR pull requests'). So a steward waiting on the approved tool sleeps through the one event it is waiting for, and must hand-poll the thread beside it. That is how the earlier #210 watcher missed the user's 19:50Z answers, and the current #210 steward is working around it the same way. Proposed: a --since \u003ciso8601\u003e (or --comments) mode in which a thread comment created after the given time satisfies the wait. Test it with the fake-gh fixture: a thread comment arriving mid-wait wakes it, and a thread comment older than --since does not.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T23:22:01Z","created_by":"Edward Langley","updated_at":"2026-09-24T23:22:01Z","dependencies":[{"issue_id":"improvise-weoe","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m56i","title":"ADR 007 leaves formula identity unspecified: no formula-line grammar, formula id vs target item id, where a _Measure target's display lives, filter-value strings","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). This is broader than improvise-i0n7 (which covers only the CategoryRow shape of _Measure formula-target rows).\n\nADR 007 does not say how a formula and its target are identified in the file or the model. An implementer hits these at once:\n\n1. **No grammar for the formula line.** Clause 5's grammar block gives header_decl, item_decl and grouped_item, but no `formula_line` or formula `target` rule for answer 5's F1 (`- Profit(profit) = revenue - cost [cat]`). `formula.pest`'s `formula = { SOI ~ target ~ \"=\" ~ expr ... }` has no id tag, and today the whole line is `formula_raw`.\n2. **Whose id is in the parentheses?** Answer 5 (Q2) is *formula* id placement. Answer 19's `Profit (profit)` is described as \"the target's id\". For a `_Measure` target these coincide (clause 1: the target's handle is its FormulaId). For a target outside `_Measure`, `Model::add_formula` creates a real item. Under answer 22 (ids globally unique), that item's ItemId and the formula's FormulaId must differ. So the file must reference the target item by id somewhere, and the ADR does not say where.\n3. **Where a `_Measure` target's display lives.** Clause 2 gives `display` to Category, Item and View, but not to Formula. km71's section says \"a target change becomes a display rename of the target\", and a `_Measure` target has no item to hold that display.\n4. **Which ItemId a formula cell's CellKey carries** for a `_Measure` target, which has no item. Also `RecordCell.measure: Option\u003cString\u003e` and `CellId::Derived { target: String }` (render.rs) name the target.\n5. **WHERE / IS filter values.** formula.pest has `filter_value = { string | pipe_quoted | bare_ident }`. Is a double-quoted `\"East Coast\"` in id-form text an id or a display? Answer 3 says every reference is an id, but a quoted string has no stated meaning under ids.\n6. **Resolving typed names at the panel edge.** If a typed display name is ambiguous (answer 19), or equals some other thing's id, which wins, or is it refused?\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:30Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-m56i","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-m56i","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:30Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"0c2ab9a3-bfb7-5273-89bb-99fc544e5a06","issue_id":"improvise-m56i","author":"agent-adr007","text":"agent-adr007 planning. This blocks improvise-cq1q.4 and improvise-cq1q.6. Items 1, 3, 4 and 5 are readings that can go in the ADR text: a formula line is '- \u003cdisplay\u003e(\u003cformula id\u003e) = \u003cid-form expr\u003e [\u003ccategory id\u003e]'; Formula carries the target's display; a formula cell's CellKey carries the FormulaId text under _measure, which is unique by answer 22; and a filter value is an item id. Items 2 and 6 are USER QUESTIONS. Item 2: add_formula creates a real item for a target outside _Measure (types.rs add_formula), so under answer 22 that item's id and the formula's id must differ. Recommendation: treat every formula target like a _Measure one, with ItemHandle::FormulaTarget(FormulaId) and no real item, so one id in the parentheses serves in every category. Item 6: the precedence when a typed name is both a display and another thing's id. Recommendation: display first, then id; an ambiguous display is refused and the refusal lists the candidates' ids.","created_at":"2026-09-25T04:51:33Z"},{"id":"e029ce2b-7fdd-5e1c-98a5-df1460a148db","issue_id":"improvise-m56i","author":"Edward Langley","text":"USER DECISION 2026-09-25 (item 2, formula target outside _Measure): 'Real item + separate formula id' -- add_formula keeps creating a real item for a non-_Measure target; the formula gets its own id; formula lines name the target by the item's id. (Chosen over the recommended 'no real item, FormulaTarget handle'.)","created_at":"2026-09-25T17:51:47Z"},{"id":"7e1c14e6-9d79-56c6-bcc5-0abcbb9eb3bb","issue_id":"improvise-m56i","author":"Edward Langley","text":"USER DECISION 2026-09-25 (item 6, a typed name matching a display and another thing's id): 'Display first; client holds it' -- match display first, then id; an ambiguous display is refused listing the candidate ids.","created_at":"2026-09-25T17:51:49Z"}],"dependency_count":0,"dependent_count":2,"comment_count":3} +{"_type":"issue","id":"improvise-le4p","title":"ADR 007: an id 'format' cannot be written as a view reference — format_line wins the ordered choice, and id_ref has no quoting arm","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30), against gh/main ad217cc1.\n\nADR 007 clause 5 says an id \"never needs pipe quoting in an .improv declaration or reference\", and its grammar gives references `id_ref = { id_token }` with no pipe-quoted arm. That is false for one id: `format`.\n\nIn improv.pest, `view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line }` is an ordered choice, and `format_line = { \"format: \" ~ rest_of_line ~ ... }`. So the axis line for a category whose id is `format` (`format: row`, `format: page, east`) matches format_line first and is read as the view's number format. The axis assignment is lost with no error. (`prune: row` and `hidden: row` fail their own rule and backtrack to axis_line, so only `format` collides.)\n\nToday this is masked by accident: `quote::KEYWORDS` contains `FORMAT` (a formula keyword, from d00cea24), matched case-insensitively, so `format_md` writes `|format|: row`. Under ADR 007, slugify folds to lower case (answer 16(b)), so any category displayed `Format` gets the id `format`, and `id_ref` cannot quote it.\n\nThe implementation must pick one: reserve `format` from slugify (suffix it like any collision), give `id_ref` a pipe-quoted arm, or reorder or disambiguate the view-section rules. A round-trip test with a category displayed `Format` on a row axis should fail first.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:28Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:28Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-le4p","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:54Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-le4p","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:28Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"bc8717a4-50fc-52a0-86ed-84adf1f7793c","issue_id":"improvise-le4p","author":"agent-adr007","text":"agent-adr007 planning: a text fix, not a user question. In the new-version arm, try axis_line (which requires an axis keyword) before format_line. No number format is an axis keyword, so neither slugify nor id_ref needs to change. Folded into improvise-cq1q.1; the test is in improvise-cq1q.5.","created_at":"2026-09-25T04:51:35Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-5jxt","title":"pr-reviews dies with 'jq: Argument list too long' on a PR with many comments (#210)","description":"scripts/pr-reviews passes the whole PR, inline-comment and issue-comment JSON to jq as --argjson arguments (fetch(), around line 109). On PR #210 (ADR 007, dozens of long comments) the argv exceeds ARG_MAX: ./scripts/pr-reviews 210 exits 126 with 'jq: Argument list too long', with or without --wait. So the approved review watcher cannot watch the busiest ADR PR at all, and a --wait over it fails in a way that is not exit 1 (timeout) or 0. Fix: feed the three documents via files or stdin (--slurpfile / --rawfile, or jq -s over a concatenated stream) instead of argv. Test: a fixture whose combined JSON exceeds getconf ARG_MAX.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-5jxt","owner":"el-github@elangley.org","created_at":"2026-09-24T22:07:30Z","created_by":"agent-adr007","updated_at":"2026-09-24T22:37:58Z","started_at":"2026-09-24T22:17:05Z","closed_at":"2026-09-24T22:37:58Z","close_reason":"Landed in main via #301.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0s6.1","title":"Sanitize the repository for a public mirror (improvise/improvise)","description":"Prepare the repository for a public release, via a public mirror at improvise/improvise (user's plan, 2026-09-24).\n\nScope:\n1. Secret scan of the FULL history (gitleaks or trufflehog), every branch and tag that would be published. Rotate anything found; decide whether history must be rewritten.\n2. Content audit: list what becomes public and decide per item — .beads/ (issues.jsonl, interactions.jsonl), prolog-sessions/kb.pl, .claude/ and .agents/, context/ (agent notes), commit author emails, agent-posted PR/issue comments (these stay on the private repo; confirm nothing is copied).\n3. Choose mirror shape: true mirror of main vs. filtered/fresh history (e.g. drop .beads/, .claude/, prolog-sessions/, or squash to a single initial commit). Once public, the choice is effectively permanent.\n4. Workflows in the public repo: self-hosted runners must NOT be attached there. Disable Actions on the mirror initially, or add a GitHub-hosted-only CI. Disable or strip claude.yml (no CLAUDE_CODE_OAUTH_TOKEN there; if ever enabled, gate on author_association OWNER/MEMBER/COLLABORATOR).\n5. Mirror mechanism: push main only, private -\u003e public, via a narrowly scoped deploy key; never push agent WIP branches.\n6. Decide where Pages, releases (cargo dist), README badges and crates.io metadata (repository URL) point.\n7. Contribution flow: how outside PRs/issues on the mirror are brought into the private repo.\n\nAcceptance: a written audit (secret-scan result + public-content list with decisions), the mirror script/workflow in a PR, and the user's go-ahead before the first public push.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T19:52:46Z","created_by":"orchestrator","updated_at":"2026-09-24T19:52:46Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6.1","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-24T19:52:46Z","created_by":"orchestrator","metadata":"{}"}],"comments":[{"id":"b8d881e5-7b7f-5471-bb86-fbe206b5b706","issue_id":"improvise-0s6.1","author":"Edward Langley","text":"Found 2026-09-25 while reading a code-maat age report: bank-info.improv was tracked and deleted in 1c2361b4 (2026-09-19) but is still in history, reachable from many branches; foo.improv (deleted 359c1577) and whatever.improv likewise. Check bank-info.improv's content in history before any public mirror -- if it holds real financial data it needs a history rewrite or a mirror cut from a clean point, which is the user's decision. Also on gh/main: a generated TAGS file (stale; still says MAX_CATEGORIES = 12) and DOLT_REMOTE.md on some branch.","created_at":"2026-09-25T03:18:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-ddp6","title":"A drill commit validates the row's categories but not its items, so a concurrent RemoveItem lets it recreate an orphan cell","description":"Codex P2 on #298, originally on #222 (https://github.com/fiddlerwoaroof/improvise/pull/222#discussion_r4090492870). RecordRewrite::of (crates/improvise-command/src/ui/effect.rs) refuses the commit when a category the row names is gone (improvise-qz9p), but it never checks that the row's coordinate ITEMS, or an edited measure item, still exist. Model::remove_item deletes every cell carrying the item, so a RemoveItem that lands between StartDrill and ApplyAndClearDrill is undone by the commit: SetCell writes a key naming an item its category no longer has. That is an orphan cell that no layout draws and that save writes out. Reachable today when a connected client removes an item while the host TUI user has a drill open with staged edits. The same gap is on gh/main fb3358a0, so #298 did not introduce it. Fix: resolve every item a row names (coordinates and edited measures) before composing, or refuse as unwritable. Add one regression that removes a coordinate item and one that removes a measure item. Pick fixtures where the removed item is not at index 0.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:15Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:15Z","labels":["adversarial-review","pr-222","pr-298"],"dependencies":[{"issue_id":"improvise-ddp6","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3h9w","title":"A captured editor's Enter is refused by the cursor's cell: refused_here runs before the capture is consulted (#274 after merging #286)","description":"Codex review of PR #274 at 8c28360f (P2), confirmed by the adversarial reviewer. The merge of #286 into #274 introduced it; it is a regression against 8d9de94e.\n\n`step()` answers `declined(name).or_else(|| refused_here(state, name))` BEFORE `committed()`/`commit()`. For the two commit keys, `refused_here` judges the **cursor's** cell (`declined_at` or `unprojected`) and ignores `state.opened`. So an editor that captured a still-valid stored cell is refused whenever a relayed reframe leaves the cursor on an aggregate, a derived cell, or an unprojected cell.\n\n**Probe** (real Host, `host()` fixture, plus a formula `Profit = Revenue - Cost`):\n1. A presses `l i 4 2`, so the capture is `Target(North, Value{Cost})`.\n2. B sets Region's axis to None. The relays are delivered, and the reset puts A's cursor on (0,0), which is now `Aggregate`.\n3. A presses Enter.\n- At **8c28360f**: no frame. The status is \"Cannot write to an aggregate…\", the buffer is still 42, and North/Cost is None. Every retry does the same, so the only way out is Esc, which loses the text.\n- At **8d9de94e**: `Accepted`, and North/Cost = 42.\n\nThis is 8xq0's TOCTOU returned as a false refusal, the shape of Codex's first P2 on this PR (dispatch from the cursor). The author's `a_keyboard_edit_whose_rows_all_vanished_writes_the_cell_it_opened_on` does not see it, because an empty grid is exempt from `unprojected` (51943d1b) and has no aggregate.\n\n**Fix direction:** apply the `declined_at`/`unprojected` guard for commit keys only when `state.opened` is None (an uncaptured editor), or derive it from `opened.target()`. Add the regression: a valid capture, with the cursor's cell changed to an aggregate or derived cell.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T02:51:47Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T21:44:51Z","started_at":"2026-09-24T03:03:50Z","closed_at":"2026-09-24T21:44:51Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-3h9w","depends_on_id":"improvise-7mro","type":"related","created_at":"2026-09-24T02:51:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-3h9w","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T02:51:54Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2zw9","title":"Browser: keys typed before a cell is projected diverge from the TUI's value (held i 7 Enter writes 7 where the TUI writes 47)","description":"After improvise-7mro (#286), a key resolved on a cell outside the held window never names the wrong record: i opens an editor awaiting its seed, and a commit/x/yy/p waits out loud. But with the host's replies held, the user types into an empty editor and the retry after the answer writes what they typed (7), where the TUI, which showed the seed, writes 47. Exact agreement needs the client to DEFER keys resolved on an unprojected cell until the projection arrives, which means on_frame handing queued frames back to web/app.js (a page-surface change) - or rejecting that as UX and documenting it. Pinned today by keys_sent_before_a_scroll_is_answered_agree_with_the_tui's Replies::Held arm, which compares written cells, not values.","notes":"Raised to P2 per review of #286: typed-ahead text accumulates across out-loud waits (i 5 Tab 3 Tab 8 Tab -\u003e next Tab writes 38 where TUI writes 8). Same design question as improvise-n4i9 (user decision pending): defer keys until the cell arrives.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:35:44Z","created_by":"agent-7mro","updated_at":"2026-09-24T02:46:28Z","labels":["browser","pr-286"],"dependencies":[{"issue_id":"improvise-2zw9","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:39Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"dcfa7281-9233-574a-9d68-d1490c1c98f5","issue_id":"improvise-2zw9","author":"agent-review-277","text":"From the adversarial review of #286 at 6b604a9e. This is a second symptom with the same fix (deferring keys), and I think it is worth P2 rather than P3.\n\nThe case is type-ahead data entry across a row wider than the window, with replies held (any socket latency). The fixture is `_Measure` rows Revenue/Profit/Cost and Months Jan/Feb, window 2 rows × 1 col, cursor on Revenue/Jan. Keys `i 5 Tab 3 Tab 8 Tab`:\n- TUI: Jan/Revenue = 105, Feb/Revenue = 8.\n- Browser at 6b604a9e: Jan/Revenue = 105. The second Tab is refused (\"waiting for the host to show this cell\"), and the refusal keeps the typed text. So the next digit appends, and the editor ends open with buffer **\"38\"**. The next Tab after the answer writes 38 to Feb.\n\nA refusal that keeps the buffer turns fast typing into concatenated values from two intended cells. At 6942e53e the same keys lost the text silently, and neither head writes a wrong record. The out-loud refusal is better than silence, but under latency it happens on every Tab at the window's edge.\n","created_at":"2026-09-24T02:45:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-dndu","title":"PR #245's merge of #228 dropped the check-harness-lock --allow-network CI step; CI now runs only the checker's self-test","description":"## Summary\nFound reviewing PR #245 at head f41a946b, the merge of #228 `1bec7c0f` into the zs2r branch.\n\nThe merge message says \"The --allow-network step is unchanged\". It is gone. `git diff 1bec7c0f f41a946b -- .github/workflows/ci.yml` is **empty**. The `test` job step that #245 added after \"Every workspace root is formatted\" is absent at f41a946b:\n\n```\n # A manifest-only change to a crate the harness reaches by path stales\n # its own lock, which only the slow `build` job saw (improvise-zs2r).\n - name: The harness lock matches the manifests it resolves against\n run: ./scripts/check-harness-lock.sh --allow-network\n```\n\nThat step was at lines 147-150 of 23b8dbf5. `grep harness-lock` on f41a946b's ci.yml finds nothing.\n\n## Effect\nCI now runs only `check-harness-lock-test.sh`, the checker's self-test, through the `kb` row of `ci_suites`. It never runs the lock check itself against the real `harness/opfs-contract/Cargo.lock`, which was the whole point of zs2r.\n\nNo gate notices:\n- `check-ci-graph.py` exits 0 (\"all 10 suites run\"). It counts suites, not this step, and the step does not mention `checkers.sh`.\n- `context/code/tooling.md` at f41a946b still says \"CI runs with `--allow-network`\" (around lines 952 and 977), so the doc now misstates CI.\n\n## Fix\nRestore the four lines above in the `test` job, after the fmt-workspaces step. The `kb` row addition stays. Then check `git diff 1bec7c0f \u003chead\u003e -- .github/workflows/ci.yml` shows exactly that step.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:15:10Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:46:00Z","closed_at":"2026-09-24T21:46:00Z","close_reason":"Landed in main via #245 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-245"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xpzh","title":"check-ci-graph splits lines on \\n only; YAML also breaks at U+0085/2028/2029, so a job if: after one is invisible","description":"## Summary\nFound in adversarial delta review of PR #228 at head 7ae871cb (the 0wn3 value allowlist, `9dc8a643..7ae871cb`).\n\n`ci_steps` splits the workflow on `\\n` only. Text-mode `open()` also folds `\\r` and `\\r\\n`, so those are safe (verified). But YAML 1.1 parsers also break lines at NEL (U+0085), LS (U+2028) and PS (U+2029). A line such as ` runs-on: ubuntu-latest\u003cU+2028\u003e if: false` is therefore two lines to YAML and one line to the gate. To the gate it is key `runs-on`, whose value is a plain scalar starting with `u`, which the allowlist accepts. The job's `if:` never reaches `if_jobs`.\n\n## Reproduce (real ci.yml)\nAdd a job `gate` with that line (`runs-on`, then U+0085/2028/2029, then `if: false`), plus `steps: [- run: echo]`. Give kb `needs: gate`, and add `gate` to pages-deploy's `needs`.\n\n- go-yaml (yq): `.jobs.gate | has(\"if\") == true`, `.if == false`. Same for U+2028.\n- PyYAML 6.0.3 and Ruby Psych 5.2.2 (libyaml): `gate.if == false` for all three characters.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- Controls:\n - a bare CR (`\\r`) at the same spot: exit 1, caught, because text-mode `open` folds it;\n - CRLF, and a whole-file CRLF: exit 1;\n - plain LF: exit 1.\n\nThe same mechanism hides kb's own `needs:` behind its `name:` line. GitHub's workflow parser is built on YamlDotNet, which also treats U+0085/2028/2029 as breaks. I have not verified that against GitHub itself.\n\n## Fix\nRefuse any line containing U+0085, U+2028 or U+2029. \"Refuse, don't parse\": these characters have no business in a workflow. Splitting on them would also work, but it is a second definition of a line. Add one case per character, and a CR control.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:50:56Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:46Z","started_at":"2026-09-24T01:54:11Z","closed_at":"2026-09-24T21:44:46Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0dbk","title":"#266 and #279 each add ClientState::notify: their merge does not compile, and #266's rebase() skips #279's settle_status","description":"Found reviewing #266 (57b713cf, improvise-xd3q/n0pb) against #279 (9eb83538, improvise-1x9h). I materialised the merge of the two heads.\n\n1. **The merge does not compile.** Git merges `client.rs` cleanly, but each side adds its own `ClientState::notify`, so the result has E0592 (duplicate definitions). The only textual conflict is in `context/test-evidence/clients.md`. A clean merge of the Rust files is exactly what makes this easy to miss.\n - #266's version queues the sentence through `fold_outgoing` (at the tail of `in_flight`).\n - #279's version writes `view.status_msg` directly and calls `settle_status`.\n\n2. **Keep #266's `notify`; #279 should adapt.** #279's direct write is the shape n0pb removed: a rebase erases it. #266's version already records an owner: `fold_outgoing` → `apply` → `settle_status`. The owner lives on `ClientState`, not per slice, so settling the queued entry later does not lose it.\n - With #279's `notify` deleted, the merged tree passes the whole workspace, including #279's `a_rejection_that_arrives_in_an_open_editor_stands_beside_it`.\n\n3. **After the merge, `rebase()` must also call `settle_status()`.** #266's `rebase()` calls `replay`, `settle_mode` and `settle_editor`, but not #279's `settle_status`. So a rebase that moves the view out of the editor a message was written in does not clear `status_owner`. Returning to an equal editor then makes the message stand again, which contradicts #279's rule that \"an equal editor opened later is another opening\".\n - Probe, in the merged tree:\n 1. `SetSelected(1,1)` → `ChangeMode(editing)` → `SetStatus`; the message stands.\n 2. Send an awaited frame.\n 3. `fold(ResetCursor)`, which rebases.\n 4. `fold_outgoing([SetSelected(1,1)])`.\n - Result: `status_stands()` is true, and should be false.\n - Adding `self.settle_status();` to `rebase()` makes the probe pass, and the whole workspace stays green.\n\nWhichever of the two PRs lands second owns both repairs. #279 is the one that has to adapt: it drops its `notify` and adds the `settle_status` call to `rebase`.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:37:59Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:59Z","closed_at":"2026-09-24T21:45:59Z","close_reason":"Landed in main via #279 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y5eb","title":"Browser settle_declined_editor reads the pre-scroll cache: a Down-wrap from a scrolled window closes an editor the TUI opens, naming a formula not under the cursor (#282)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. PR #282 introduces this; PR #277 alone (aab62a36) does not have it.\n\n`ClientState::settle_declined_editor` runs after every fold, including the fold of the client's OWN outgoing frame. The frame for an advance is `scrolled_to` (SetSelected, SetRowOffset/SetColOffset) and then the open (SetBuffer, SetOpenedEditor, ChangeMode). By the time ChangeMode(Editing) is folded, the view's offsets are the NEW ones, but the GridCache still holds the OLD window, because the host has not answered yet. `declined_at` -\u003e `cell_at` subtracts the new offsets and indexes the old cache, so it reads a different cell from the one under the cursor.\n\nA one-step scroll happens to read the cell just committed, which is editable, so it is harmless. A Down that WRAPS from the bottom row to (0, col+1) does not. It jumps the row offset back to 0, and cache row 0 is then the old window's top row.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed). `_Measure` on rows [Revenue, Profit, Cost], where Profit is an explicit item AND `Profit = Revenue - Cost`. Month [Jan, Feb] on columns. Client window (2, 6), placed at (2,0) with row_offset 1, so the window shows Profit and Cost. Keys `i`, `Enter`:\n- TUI: lands on (0,1) Revenue/Feb in Editing.\n- Client, straight after fold_outgoing(Enter): (0,1), mode Normal, status \"Profit is computed by a formula — edit the formula, not the cell\". Profit is not the cell under the cursor.\n- Delivering the host's reply does not repair it, because the settle only ever closes an editor.\n\nThis also holds on a materialised merge of #282 with #272 (which includes #227). There the awaiting-seed editor is untouched too, so the settle closes it the same way.\n\nA fix direction, not a prescription: settle only against a cache framed for the view's current offsets (the row/col frames' `first` equal to the cursor offsets), or skip while this client's own scroll is unanswered.\n\nAC: a lockstep test with a Down-wrap from the bottom of a scrolled window onto an editable cell whose stale-window counterpart is declined. The client and the TUI must agree on the mode. A rival that removes the guard must fail it.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:00Z","created_by":"agent-review-277","updated_at":"2026-09-24T21:46:02Z","closed_at":"2026-09-24T21:46:02Z","close_reason":"Landed in main via #282 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0wn3","title":"check-ci-graph: a multi-line flow value with an open string is not refused, and a key-shaped closer makes its content read as a counted run:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 9dc8a643 (the 6zf1 fix, `588094b7..9dc8a643`). That fix leaves \"flow values spanning lines\" unrefused, on the argument that they fail closed. They do not, when the flow value's closing line is spelled like a step key.\n\nA step key whose value opens a flow collection (`[a, \"b`, or `{a: \"b`) with a quoted string that stays open is a plain inline value to the gate. `_opens_quote` answers only for a value that *starts* with a quote. The owner rule then owns only lines deeper than the step's key column, 8. So the lines *inside* the flow value at indent 8 are read as step keys, including an exact `run: …checkers.sh --ci kb`.\n\n## Reproduce (real ci.yml)\nReplace kb's last step with:\n```\n - name: [a, \"b\n run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb\n c: x\"]\n```\n- go-yaml (yq), PyYAML 6.0.3 and Ruby Psych 5.2.2 all read the step as `{\"name\": [\"a\", \"b run: nix shell … --ci kb c: x\"]}`, a step with **no `run:`**.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb's checkers step never runs.\n- The same with a single-quoted string: exit 0.\n- The same as a flow map, `{a: \"b` … `c: x\"}`: exit 0.\n- Controls:\n - a closer that is not key-shaped (`c\"]`): exit 1, refused as unreadable. This is the case the fail-closed argument covered.\n - a closer whose value is quoted (`c: \"x\"]`): exit 1. It is also invalid YAML.\n - the no-quote form `[a,` … `c: x]`: all three parsers reject it.\n\n## Fix\nIn keeping with \"refuse, don't parse\": refuse any inline value that begins a flow collection (`[` or `{`) and does not close it on the same line. A bracket count outside quotes is enough, or simply require the value to end in the matching bracket. The one-line `needs: [a, b]` form stays readable. Add one case each for `[`, for `{`, and for the single-quoted form.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:25:52Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:42Z","started_at":"2026-09-24T01:32:11Z","closed_at":"2026-09-24T21:44:42Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4k1j","title":"Browser H under nested row categories hides a group name: hidden_row_item's fold test is the nearest group, not the row's fold","description":"Found reviewing PR #275 (improvise-fo0w) at cdefeeb9. It is the client-side return of improvise-yk2s, which was fixed on the host. resolve.rs::hidden_row_item refuses on a fold only when frame.group_of(idx) is collapsed. But group_of is the NEAREST group header. With two row categories, where the outer one (Region) has a collapsed group Coast and the inner one (Product) has its own group Kit, the fold rows (Coast, Gadget) and (Coast, Gizmo) have Kit as their nearest header. Kit is expanded, so the client does not refuse. It composes HideItem{Region, item: \"Coast\"}, a group name, and the twin host accepts it and writes 'hidden: Region/Coast'. The host (HideSelectedRowItem) asks layout.row_folds_a_group, which reads the DataItem's own collapsed flag, and refuses with FOLD_REFUSAL. Reproduced by sweeping H over every row of four grouped fixtures in lockstep: 2 of 30 rows diverge. The divergence appears with a 2-row window at an offset and with a whole-grid window. It survives a merge with #263 (the e265/uv9e stack). hiding_on_a_fold_refuses_as_the_host_does uses one row category, so its fixture lacks the structure. Fix direction: the frame must carry the fold fact itself, as the per-item collapsed flag the layout already has, rather than the client inferring it from the nearest header.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:15:13Z","created_by":"agent-review-264","updated_at":"2026-09-24T21:46:01Z","closed_at":"2026-09-24T21:46:01Z","close_reason":"Landed in main via #275 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6zf1","title":"check-ci-graph: the quote tracker closes a multi-line string on an escaped \\\" or doubled '', so a later quote reopens it over a real if:","description":"## Summary\nFound in adversarial delta review of PR #228 at head 588094b7 (the j49r fix, `5738e8e0..588094b7`). The bug predates that delta: it also exits 0 at 5738e8e0, and it has been present since the quote tracker landed (st8q).\n\nWhile a multi-line quoted value is open, `ci_steps` closes it on **any** line that contains the quote character (`if quote in raw`). YAML does not close it on an escaped quote (`\\\"` inside double quotes) or on a doubled quote (`''` inside single quotes). So the tracker closes early, and the rest of the string is read as structure. A later content line such as `b: \"` then looks like a key that opens a new quote, and that phantom string swallows the job's real keys until the next quote character.\n\n## Reproduce (real ci.yml)\nInsert before `kb:`, give kb `needs: gate`, and add `gate` to pages-deploy's `needs`:\n```\n gate:\n runs-on: x\n name: \"a\n it\\\" is\n b: \"\n if: false\n steps:\n - run: echo \"z\"\n```\n- yq-go: `gate.name == 'a it\" is b: '`, `gate.if == false`, `kb.needs == \"gate\"`.\n- `check-ci-graph.py` **exits 0**, \"all 9 checkers.sh suites run in CI\". kb is skipped on a PR.\n- The single-quoted form also exits 0: `'a` / `it''s` / `b: '`.\n- The same with the reopening line at indent 4: exits 0.\n- Control, a plain two-line quoted name: exit 1, caught as conditional.\n\n## Fix\nClose the tracker only on an unescaped closing quote: ignore `\\\\.` for a double-quoted value, and `''` for a single-quoted one. This mirrors `_opens_quote`. With exactly that change, both plants exit 1 and all 47 self-test cases still pass, so no existing case covers the hole. Add one case per quote style. Alternatively, refuse any multi-line quoted value, which is in keeping with \"refuse, don't parse\".","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-228-j49r","owner":"el-github@elangley.org","created_at":"2026-09-24T01:10:40Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:45Z","started_at":"2026-09-24T01:13:33Z","closed_at":"2026-09-24T21:44:45Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8phw","title":"A keyboard value-edit retry after a crossing LayoutMoved writes the cell now under the cursor (8xq0's crossing twin, 9ww8 for values)","description":"Found reviewing PR #274 at head 7bf8debd (improvise-8xq0's fix). This is the value-cell twin of improvise-9ww8.\n\n#274 captures the opened cell once the host has answered. The 8xq0 test delivers B's HideItem to A BEFORE A presses Enter, so A's capture from when the editor opened is used, and it is correct. But when A's Enter crosses B's HideItem in flight (ordinary latency):\n- A's frame is refused `LayoutMoved`, because hiding a row moves the layout tier.\n- `refused()` replays the settled slice. The editor is back at (0,1) with buffer \"42\", and `settle_opened` recaptures what is now under the cursor, which is **South**/Cost at its current version.\n- The retry Enter is accepted, so 42, typed for North/Cost, lands on South/Cost.\n\nProbe (real Host, `host()` fixture, the keys `l i 4 2`, B's relays held until A's Enter is answered):\n- first answer `Rejected(LayoutMoved)`;\n- after the refusal, `opened.record` is South's hash (it was North's before);\n- retry `Accepted`;\n- cells after: North/Cost None, **South/Cost 42**.\n\nThe force control holds: if B writes North/Revenue instead, the refusal is `RecordMoved(north)` and the retry writes North/Cost (it is a force on the same cell).\n\nFix direction: #251's `Opened::Retry(r)` rule (`ff8ea239`) already solves this for renames. Its `renamed()` only matches `RenameRecordCoordinate`, and its comparison is on the record only. When #274 and #251 are merged:\n- `renamed()` should also match `SetCell`/`ClearCell`, as the record plus the measure;\n- `Retry` should compare the whole `OpenedTarget` (record + measure, or record + category) before it accepts the recapture.\n\nA test must hold B's relays until after A's Enter.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T01:04:42Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T21:44:51Z","started_at":"2026-09-24T01:14:45Z","closed_at":"2026-09-24T21:44:51Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-8phw","depends_on_id":"improvise-8xq0","type":"related","created_at":"2026-09-24T01:04:52Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-8phw","depends_on_id":"improvise-9ww8","type":"related","created_at":"2026-09-24T01:04:53Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-eahe","title":"shipped_documents_load test binary exceeds the 2s tripwire: one child process per env setting","description":"Measured on #276 (M4 Max, load ~120): tests/shipped_documents_load.rs takes 2.6-3.7s, over design-principles §7's 2s per-binary tripwire. Cause: child-process reruns added by 53zz/vwjj/pr3i/a0i3/2iws — a0i3 alone spawns ten (five settings x two tests). Fix: set all the hostile settings in ONE child per test (the rivals already show each setting breaks the walk on its own, so a combined child still discriminates per-setting via rivals), or otherwise reduce child count. Measure before/after naming the machine; report the ratio to the run's median binary.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:49Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:09Z","started_at":"2026-09-24T01:03:22Z","closed_at":"2026-09-24T21:45:09Z","close_reason":"Landed in main via #283 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n0pb","title":"A relay heard while a frame is in flight erases the rejection sentence (PR #266 rebases view from settled, and the sentence is only in view)","description":"Found in adversarial review of PR #266 (improvise-xd3q) at e4b5ce90. The PR introduces it: at its base, 790a33ba, the same probe passes.\n\n`Client::fold_frame` (crates/improvise-wasm-client/src/socket.rs) handles `ServerFrame::Rejected` by writing the rejection sentence directly into `state.view.status_msg`, after `refused` rolls back. That sentence lives only in `view`: it is never in `settled` and never in `in_flight`. #266 makes `ClientState::fold` rebuild `view` from `settled` plus `in_flight` whenever a relay queues behind an in-flight frame. So the next such relay erases the sentence, and the user loses the one notice that their edit was not applied. `last_rejection` survives, but the sentence is gone.\n\nReproduced through the page's own entry point, `Client::fold_frame`:\n1. Two awaited frames go out through `fold_outgoing`, each a `Command::Model`.\n2. The first is refused with `Rejected(LayoutMoved)`, and the sentence is set.\n3. A relay, `Commands([ResetCursor])`, arrives while the second is still in flight.\n\nAt e4b5ce90 the result is `status_msg == \"\"`. At 790a33ba it is the sentence. This is reachable whenever the refused frame is followed by an accepted frame that relays: a pruning commit's reset and echo, or another client's reset. (A second refusal would write a fresh sentence, which is why it has to be an accepted frame.)\n\nFix options:\n- Write the sentence into `settled` as well as `view`.\n- Or keep it outside `ViewState` and have `rebase` preserve it.\n\nThe same hazard applies to any other write into `view` that a later rebase does not reproduce. `settle_editor`'s seed is re-derived by `rebase`, so it is safe. This sentence is the one case I found.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:55:16Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:16Z","started_at":"2026-09-24T01:24:26Z","closed_at":"2026-09-24T21:45:16Z","close_reason":"Landed in main via #266 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j49r","title":"check-ci-graph: a job header with a trailing comment is accepted but never opened, so its if: and the job itself vanish from both rules","description":"## Summary\nFound in adversarial delta review of PR #228 at head 5738e8e0 (the rjaa fix, `3eafd4de..5738e8e0`).\n\nA job header with a trailing YAML comment, such as ` gate: # skipped on purpose`, is valid YAML. GitHub reads it as job `gate`. The gate disagrees with itself about the line:\n- `_readable` is handed the comment-stripped `line`, so it accepts the header;\n- `_JOB.match(raw)` in both `ci_steps` and `read_graph` runs on the raw line with the comment, so it does not match.\n\nThe job is therefore never opened. Its keys, `if: false` included, are attributed to the previous job, and `read_graph` never lists it at all.\n\n## Reproduce (real ci.yml)\nInsert, before ` wasm:` so that the preceding job is `test`, which is not counted:\n```\n gate: # skipped on purpose\n if: false\n runs-on: ubuntu-latest\n steps:\n - run: echo\n```\nGive kb `needs: gate`. Leave pages-deploy unchanged.\n\n- yq-go: `.jobs.gate.if == false`, `.jobs.kb.needs == \"gate\"`, and 8 jobs besides pages-deploy.\n- `check-ci-graph.py` **exits 0** with \"pages-deploy needs all 7 other jobs; all 9 checkers.sh suites run in CI\".\n\nSo both rules fail open at once. On a PR, kb is skipped behind gate. And pages-deploy does not need gate (x7cq's own rule), which nothing reports.\n\n## Controls\n- A trailing tab or trailing spaces on the header: refused (exit 1), because `_readable` sees them too.\n- A comment on the `if:` line: caught.\n- A `mid: # c` header in a two-hop chain: exit 1, but only by accident. Every attempt I made at a clean version was caught by some other rule.\n\n## Fix\nMatch the job header on the same comment-stripped `line` everywhere (`ci_steps` and `read_graph`), or refuse a comment on a job header. Add a self-test case and a `read_graph` graph case.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:47:23Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:44Z","started_at":"2026-09-24T00:53:00Z","closed_at":"2026-09-24T21:44:44Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xz2z","title":"The keyboard cell commit claims its basis at commit, not open: a value another client wrote while the editor was open is silently overwritten (0j0f's other door)","description":"Found while scoping improvise-3al1. improvise-0j0f was fixed only on the MOUSE path: `typed_cell` / `EditTarget` claims the record at the version it READ when the editor opened (`BasisCache::claim_as_read`). The KEYBOARD path does not. `resolve.rs::written`, reached from `commit` (Enter/Tab in Editing), calls `state.basis.claim([record_at_cursor(state)?])`, which states the record's CURRENT counter at commit time. The fold keeps running while the editor is open, so another client's write to the same record bumps that counter, and the commit claims the fresh one and is accepted.\n\nReproduced with a scratch test (not committed): a real Host, two sessions, North/Revenue = 1200, window 2x1.\n- Client A presses `i Backspace 4 2` (buffer \"12042\").\n- Client B writes North = 999 through the host. A folds the relays; A's editor stays open.\n- A presses Enter. The host ACCEPTS it: North ends at 12042. B's 999 is gone, A gets no Rejection, and B is told nothing.\nThe result is identical on gh/main 202e7eac and on the #227+#272 stack (9872cc93).\n\nThis is 0j0f's lost update, reached through the other door. It also blocks improvise-3al1: class-1 rejection, the thing force exists to answer, is essentially unreachable from the keyboard, because a concurrent write is overwritten instead of refused. The relay race (a write that reached the host before its relay reached the client) is the only keyboard case that can be refused.\n\nLikely fix: `OpenedEditor` captures what `EditTarget` captures (the record, the version read, and the generation, per improvise-vndi) when `opened_on` opens it, and `written` claims with `claim_as_read`. An awaiting editor (improvise-3uzg) would capture them when its seed settles.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:43:29Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T21:44:53Z","started_at":"2026-09-24T00:45:02Z","closed_at":"2026-09-24T21:44:53Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-yc9r","title":"TUI opens the editor on a NonItem cell and refuses only at commit, stranding the user in Editing; the TUI/browser NonItem declines are unpinned (#265)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814. The PR introduces this.\n\n#265 adds CellId::NonItem and declines it in the TUI's value_cell_key/committable_cell_key (p, x, commit). But EditOrDrill (i / Enter) never asks, so the TUI opens an editor on a NonItem cell and refuses only at commit. The browser declines at open (resolve.rs open-editor arm), so the two front ends now disagree.\n\nProbe (reviewer's, not pushed): Region on page with its selection set to \"Mars\" (no item), Month on row, _Measure on column. At adb6b814:\n- `i` enters Editing.\n- `5`, then Enter: the mode stays Editing, the status is \"commit-cell-edit: Mars is no item of Region, …\", and no cell is stored.\n\nBecause the mode stays Editing, draw_bottom_bar draws the minibuffer instead of the status line (gotcha 21), so the user sees nothing happen. NonItem is the only CellId kind that opens and then refuses: EditOrDrill refuses Aggregate/fold and Derived at open.\n\nThe TUI declines are also unpinned. Each rival below was injected and run over the full workspace (`cargo test --workspace --no-fail-fast`), and each stays green:\n- RE: value_cell_key's NonItem arm returns the cell key, so p/x write the non-item key.\n- RG: committable_cell_key treats NonItem like Record. A commit on a stale page then writes a NEW orphan record {Region=Mars,…}, which is the gw18 shape #252 and #265 exist to stop.\n- RF: the browser's open-editor NonItem arm returns None instead of a SetStatus decline.\nThe PR body's claim that \"p, x and a commit refuse with that sentence in the terminal\" is untested.\n\nAC:\n- EditOrDrill refuses a NonItem cell at open, with non_item_refusal, as it does for Derived.\n- Tests fail under RE, RG and RF, with at least one TUI test driving a stale page or an orphan records row through `i` and Enter.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:59Z","created_by":"agent-review-252","updated_at":"2026-09-24T21:44:58Z","started_at":"2026-09-24T00:47:03Z","closed_at":"2026-09-24T21:44:58Z","close_reason":"Landed in main via #292/#265 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-265"],"comments":[{"id":"ef641b5d-1b9c-50e1-ac19-e780bfb12869","issue_id":"improvise-yc9r","author":"agent-review-277","text":"From the adversarial review of PR #277 (head aab62a36), which conflicts with #265 in `committable_cell_key`. #277 routes that function through a new `cmd::core::uneditable`, which has no NonItem arm. I materialised the #277 x #265 merge. The exhaustive matches force a decision in both `uneditable` and `resolve::reopened_at`, and nothing forces the right one: a resolution that puts `CellId::NonItem` in `uneditable`'s editable (`None`) arm compiles, and the whole workspace passes (`cargo test --workspace --no-fail-fast`). That is this ticket's rival RG, reached through a merge. Under it a TUI commit on a stale page stores `Month=Jan, Region=Mars, _Measure=Revenue = 7`. The correct resolution is `Some(CellId::NonItem { category, item }) =\u003e Some(non_item_refusal(\u0026category, \u0026item))`, which refuses with \"Mars is no item of Region, …\". With that resolution, #277's reopen also declines NonItem, while `i` (EditOrDrill) still opens on it. That is this ticket's first AC.\n","created_at":"2026-09-24T01:29:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-4daa","title":"first_non_item clones every item name per coordinate per cell: grid_of ~10x slower in pivot, ~140x in records mode (#265 regression)","description":"Found in adversarial review of PR #265 (improvise-gk51), head adb6b814, which is stacked on #252 (47b166ee). The PR introduces this.\n\n#265 makes project::cell_id call Model::first_non_item for every non-aggregate, non-derived cell, in both the pivot and records branches. first_non_item calls Model::effective_item_names(cat) once per coordinate. That clones every item name of the category into a fresh Vec\u003cString\u003e, and for _Measure it also walks the formulas, just to run one `contains`. The TUI calls grid_of over the WHOLE grid on every frame (draw.rs, gotcha 44), so the cost lands on the per-frame path.\n\nMeasured with `cargo test --release`, the same bench at both commits, on the reviewer's machine (Darwin, the orchestrator's host), mean of 5 and 3 runs:\n- pivot 150x120 (18k cells; A 150 items rows, B 6 x _Measure 20 cols, C 50 on page): 111 ms per grid_of at 47b166ee, 1086 ms at adb6b814 (~10x).\n- records mode, 2000 records x 2 measures, a Label category with 2000 items: 1.6 ms at 47b166ee, 225 ms at adb6b814 (~140x). The cost is quadratic in a per-row label category, which is exactly what import makes Label categories for.\n\nFix shape, verified: answer the same question without allocating. `self.category(cat).is_some_and(|c| c.items.contains_key(name))`, OR (for a measure category) any formula whose target_category is a measure and whose target == name. With that body the bench is 234 ms pivot / 3.1 ms records, and the improvise-command, improvise-core and improvise test binaries all pass. The residual ~2x over base on the pivot case is still worth a look. A shared helper `Model::is_item(cat, name)` would also serve App::non_item_refusal (the #252 write path, same clone per write).\n\nAC: first_non_item allocates nothing per call. A timing or allocation guard (or a documented bench) shows grid_of within noise of 47b166ee on both fixtures.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:57Z","created_by":"agent-review-252","updated_at":"2026-09-24T21:44:58Z","started_at":"2026-09-24T00:47:02Z","closed_at":"2026-09-24T21:44:58Z","close_reason":"Landed in main via #292/#265 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-265"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-wpxr","title":"A wire write to an empty record resolves through the HOST's layout: refused RecordGone when the host sits on a view hiding it","description":"Found while reviewing PR #267 (improvise-3til) at head 2c16f7f2.\n\nApp::resolve_record tries the DataStore index first. If the record has no stored cells, it falls back to record_in_layout, which reads self.layout, and that is the HOST's active view. Since improvise-jytm, the host's view is not the client's. So a client's write to an EMPTY cell succeeds or fails depending on which view the host is on.\n\nWITNESS (a scratch test in ui/host.rs, run and then discarded). It uses host() (Region x _Measure, no stored cells) and adds a view \"Other\" that hides Region/South. The client connects and its Snapshot names view \"Default\", where South's empty cell is drawn.\n- Control: with the host on Default, the client's `host.model(SetCell(addressed(\"South\"), 3))` is Accepted.\n- Same setup with the host switched to Other (switch_to_view plus rebuild_layout): the identical write is Rejected(RecordGone). The client is still on Default and can see the cell.\n\nThis is the record-shaped twin of improvise-obtu, which covers aggregates only. gotcha 44 says a RecordHash is \"document-scoped\" and \"works from any view\". That holds only for records with stored cells. An empty record is layout-scoped, through the host's layout.\n\nLikely fix shape: resolve against a layout derived for the ASKING session's view, as obtu proposes for aggregates. Or resolve an empty record from the document's categories, independent of any layout. The second must still refuse a group, formula or aggregate cell (unwritable, ciw8/xur5).\n\nNOTE for tests: improvise-3til's test (PR #267) deliberately has no follow-up write. A fixture in which the host's two views differ in which records they show WOULD make a write discriminate the reordered rival. But the answer it would pin is this bug. Do not add such a write until this is fixed.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wpxr","owner":"el-github@elangley.org","created_at":"2026-09-24T00:37:55Z","created_by":"agent-review-267","updated_at":"2026-09-24T21:45:25Z","started_at":"2026-09-24T00:39:12Z","closed_at":"2026-09-24T21:45:25Z","close_reason":"Landed in main via #281 (reviewed; merged 2026-09-24).","labels":["adr-001","adversarial-review","pr-267","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pb30","title":"The column group toggle's cursor-follow is unguarded: three rivals in landing/place_cursor pass the whole workspace","description":"Found reviewing PR #263 (improvise-uv9e) at 2ea24074. The new test a_column_toggle_names_the_group_under_the_cursors_column pins only which group toggle-col-group-under-cursor names. The rest of the is_row:false path in ToggleGroupAtCursor (cmd/grid.rs) is untested. Each of these rivals was injected and run over cargo test --workspace --no-fail-fast, and each gave 1948 passed, 0 failed: (a) place_cursor's column arm swapped to (at, ctx.selected.1); (b) landing's column arm replaced with None, so there is no cursor-follow; (c) landing reads col_tuple(0) in place of col_tuple(ctx.selected.1). Rival (c) also needs a fixture with two column levels, because with one column category the prefix is always empty. The row half's equivalent is guarded by toggling_a_group_collapses_that_group_and_the_cursor_follows_it and its siblings. Fix: a two-level column fixture with the cursor in the second outer item on a nonzero inner run. Assert the cursor's column after the toggle is applied, and that the row is unchanged, off the origin.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:16Z","created_by":"agent-review-263","updated_at":"2026-09-24T21:45:02Z","started_at":"2026-09-24T00:35:56Z","closed_at":"2026-09-24T21:45:02Z","close_reason":"Landed in main via #273 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-p6v6","title":"An editor opened with i/a while an earlier write is unanswered seeds from the stale cache: i 9 Tab Esc i 5 Enter stores 15 where the TUI stores 195","description":"Found by the adversarial delta re-review of PR #227 at de4a7d78. This bug is PRE-EXISTING. I reproduced it against gh/main 202e7eac's protocol and command sources, with the same numbers. It is the same class as improvise-mdfc, but reached through a door that #227 does not touch.\n\nmdfc made the ADVANCE await whenever any awaited frame is in flight. The other door into the cell editor is `i`/`a`, resolved by edit_or_drill, which calls opened_on(cell.edit_text). That door still seeds from the client's cache with no in-flight check. The same holds for any other opener that reads seed_at/edit_seed while a write is unanswered, for example a click-edit, which is the improvise-ugj4 path; I did not probe that one.\n\nProbe: a real Host and ClientState. The fixture is one measure column. The cursor is on North = 1, which is the rightmost column. Keys: i 9, then Tab (writes 19 and reopens on the same cell), Esc and i, all sent before any answer arrives. Then the answers are delivered, then 5 Enter.\n- Browser: buffer \"1\" after i, and it stores North = 15.\n- TUI: buffer \"19\", and it stores 195.\nSo the browser overwrites the value the user has just committed, with a number built from the stale cache.\n\nThe reachable shape is a quick Esc and re-edit, or holding a key, over any latency. The fix is the same rule, stated once, for every editor opener rather than only for advanced(). An opener awaits the host's answer whenever `state.in_flight.iter().any(|s| s.awaited)`. The mdfc condition could move into opened_on itself, so that no opener can forget it.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:54Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:56Z","started_at":"2026-09-24T00:27:11Z","closed_at":"2026-09-24T21:44:56Z","close_reason":"Landed in main via #272 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rjaa","title":"check-ci-graph: a needed job whose keys are indented past 4 is read as content, so its if:/needs: are missed and nothing is refused","description":"Found in adversarial delta review of PR #228 at head 3eafd4de (the w0j4 fix, `2aafff3b..3eafd4de`).\n\nThe w0j4 refusal checks a job's keys only at indent 4. `_readable` returns True for any line indented deeper than 4 when it is not inside `steps:` or `needs:`. A job whose keys are indented 5, 6 or more is therefore read as all content. That is legal YAML, and GitHub reads it the same way. Its `if:` never reaches `if_jobs`, its `needs:` never reaches `needs`, and nothing is refused.\n\nPlanted on the real ci.yml. In each case `gate` is also added to pages-deploy's `needs`, and yq-go confirms both the `needs` edge and gate's `if: false`:\n- **A needed job with 6-space keys.** Add `gate:` with keys at 6 (`if: false`, `runs-on`, `steps:` with an item at 8), then give kb `needs: gate`. The gate exits 0, \"all 9 checkers.sh suites run in CI\", yet kb is skipped on a PR.\n- **The same with 5-space keys** and `needs: [gate]`: exit 0.\n- **A two-hop version.** A 6-space job `mid` holds `needs: gate`, and kb has `needs: mid`. That exits 0 too, so the transitive walk is cut as well.\n\nThe counted job itself cannot use this, because its steps are then not recognised and the gate fails loudly. The hole is every job it needs, directly or transitively.\n\nFix direction, in keeping with \"refuse, don't parse\": the first key line after a job header must be at indent 4, or it is refused. Any line at 5–8 that is not inside `steps:`/`needs:` must sit under a 4-space key with an empty value. Add a case for a needed job with 6-space keys.\n\nLoud false refusals seen in the same review (not bugs, noted for the doc): the gate also refuses three plain, valid styles, each by naming the line:\n- an indentless block list (`needs:` then ` - build` at 4);\n- a block list at 8;\n- a top-level `\"on\":`, the yamllint-truthy style.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:09:51Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:45Z","started_at":"2026-09-24T00:31:21Z","closed_at":"2026-09-24T21:44:45Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9ww8","title":"A coordinate-rename retry after a crossing LayoutMoved renames whichever record is under the cursor (20ek's fix pins only RecordGone)","description":"Found reviewing PR #251 at head 29d2adef (improvise-20ek's fix). The 20ek fix pins the editor only on `Rejected(RecordGone(r))` where r is the refused rename's own record. But `stale_basis` checks the layout tier BEFORE the record tier, and a coordinate rename by another client bumps `layout_version` (measured 2 -\u003e 3). So when A's rename frame crosses B's rename in flight (A composed Enter before hearing B), the refusal is `LayoutMoved`, not `RecordGone`. `refused()` does not pin; the replay reopens the editor, `settle_opened` recaptures whatever record is now under the cursor at its current version, and the retry Enter renames THAT record with text typed for another.\n\nTwo probes, real Host, three_records_host (North 1, South 2, West 3), B's relays to A held until after A's Enter is answered:\n\n1. Source gone: A edits North, types Zed; B renames North -\u003e Yak. First answer `Rejected(LayoutMoved)`; editor reopens over South; retry: South -\u003e Zed. After: North None, South None, Zed 2.0, Yak 1.0.\n2. Source survives: A edits South (row 1), types Zed; B renames North -\u003e Yak, so South shifts to row 0 and West is under the cursor. First answer `Rejected(LayoutMoved)`; editor reopens over West; retry: West -\u003e Zed. South still exists, untouched, and West's 3.0 is now Zed. So the discriminator is not \"is the source gone\" at all.\n\nThe author's test `a_retry_after_record_gone_renames_no_other_record` delivers B's rename to A BEFORE Enter, so A's layout claim is current and only the record tier can fire; the crossing case, which only needs normal network latency, is untested.\n\nSuggested direction (not verified): after any refusal, a recapture must name the same record the refused rename named; if the cursor now sits over a different record (or none), decline as `Gone` does. `RecordMoved` on the same record stays a one-keystroke force. A test must hold B's relays until after A's Enter, and include the source-survives case.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:32:25Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T21:44:49Z","started_at":"2026-09-24T00:02:49Z","closed_at":"2026-09-24T21:44:49Z","close_reason":"Landed in main via #251 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-9ww8","depends_on_id":"improvise-20ek","type":"related","created_at":"2026-09-23T18:32:31Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-w0j4","title":"check-ci-graph fails open on quoted, anchored or multi-line needs: and on quoted/spaced if:/continue-on-error keys","description":"Found in adversarial delta review of PR #228 at head 2aafff3b (fix 1501def5, improvise-bbwa/1be2).\n\nThe bbwa fix reads `needs:` names as raw text, and job/step keys only in bare `key:` form. Several legal YAML spellings of the same workflow therefore fail open: the kb job is skipped on a PR, and the gate still exits 0 with \"all 9 checkers.sh suites run in CI\".\n\nReproduce on the real ci.yml:\n1. Add a job `gate` with a job-level `if: false`.\n2. Give `kb` one of the `needs:` spellings below.\n3. Add `gate` to pages-deploy's `needs`.\n\nyq-go confirms that kb needs `gate` in every case.\n\n- `needs: [\"gate\"]`: exit 0. The check compares against `\"gate\"`, quotes included.\n- `needs: 'gate'`: exit 0.\n- A block list with `- \"gate\"`: exit 0.\n- `needs: \u0026d [gate]` (an anchor on the value): exit 0.\n- `needs: \u0026d` followed by a block list: exit 0. The owner rule swallows the list.\n- A flow list over two lines, `needs: [build,` then `gate]`: exit 0.\n- Controls: `needs: [gate]`, `[ gate ]` and `[gate] # c` all exit 1.\n\nThe same key-recognition gap applies to the disqualifiers themselves. It is pre-existing from tvte, but it is the same class. Each of these exits 0:\n- a quoted job key, `\"if\": false`, on kb or on its step;\n- `'continue-on-error': true`;\n- `if : false`, with a space before the colon;\n- an explicit key, `? if` / `: false`.\n\nAlso found: the 1be2 self-test fixture puts the continuation two columns past the key, not at the boundary. So a rival that marks multiline only when `indent \u003e owner + 1` passes all 23 cases. On the real ci.yml, a 9-space `|| true` continuation (valid YAML, which yq reads as part of run:) then exits 0.\n\nFix direction:\n- Unquote scalar needs names.\n- Refuse, or report, any `needs:` whose value is not a plain name, a flow list on one line, or a block list of plain names.\n- Treat a quoted or spaced key as its bare form, or report it.\n- Add a boundary (owner+1) continuation case.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:08Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:44:46Z","started_at":"2026-09-23T23:58:21Z","closed_at":"2026-09-24T21:44:46Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xd3q","title":"A relayed ResetCursor clobbers the placement of a frame the asker sent before the reframe was answered: client cursor splits from its mirror, and the next write lands on another record","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9. It is NOT #227's bug. The path touches none of #227's changed lines: host.rs is unchanged, and client.rs changes only the editor settle. The mechanism is #230's echo (improvise-wdmp). I did not re-run it at gh/main.\n\nA frame A whose write reframes the grid (a prune under prune_empty) is answered with ResetCursor, then an echo of A's own placement (wdmp's `asker_reset \u0026\u0026 places_cursor`). A frame B that the client sent BEFORE A's answer arrived was already folded optimistically. B's SetSelected is clobbered by A's relayed reset, and it is never echoed, because the host's asker_reset is per frame and B's handling saw no reset. The host's mirror keeps B's placement.\n\nProbe: a real Host and ClientState, fixture North/South/West/East = 1/2/3/4 on one measure, prune on. Keys i Backspace Enter (unanswered), then Enter (unanswered), then both answers are delivered. The client cursor ends at (1,0) while the mirror is at (2,0). The TUI is at (2,0). The next \"5 Enter\" writes West=35, where the TUI writes East=45: a write to a different record from the one the TUI touches.\n\nRelated to improvise-jpba (a refusal replay loses relays) and improvise-3kfc, but distinct: no refusal is involved. #246's relay queueing may or may not cover it. Check it there.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:47Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:45:16Z","started_at":"2026-09-24T00:09:44Z","closed_at":"2026-09-24T21:45:16Z","close_reason":"Landed in main via #266 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mdfc","title":"An untouched commit reopens from the cache while an earlier write is unanswered: pipelined Tab Tab at the right edge overwrites the value just committed (#227)","description":"Found by the adversarial delta re-review of PR #227 (improvise-fy9o) at head baabe1a9. It is a regression that #227 introduces. It is the 3tt4/3uzg harm again, reached by pipelining.\n\nresolve::advanced reopens the editor as OpenedEditor::awaiting() only when THIS commit writes. A commit that writes nothing (an untouched Enter or Tab) still seeds at once from the cache: `false =\u003e opened_on(seed_at(state, nr, nc), edit_mode)`. protocol.md §3 justifies this with \"Nothing changed, so the cache is current\". That is false while an EARLIER write is still unanswered: the cache still predates it. settle_awaited_seed never repairs the seed, because the editor it opened is not awaiting.\n\nReproduced in two ways:\n1. Real Chromium over the worker, harness fixture tall.improv, R01/Cost = 50 (the rightmost column), keys pressed back to back: i 9 Tab Tab 5 Enter. The browser stores 505 in 5 runs out of 5. The TUI stores 5095. So the browser overwrites the 509 the user has just committed.\n2. A Rust probe with a real Host and ClientState. The second Tab is sent before the first is answered. The browser buffer is \"1\" (stale) where the TUI's is \"19\", and North ends at 15 in the browser against 195 in the TUI. With prune_empty, a pipelined Enter Enter does the same: a stale seed at a renumbered position.\n\nEvery #227 test answers each key before the next one is sent (lockstep's sent_and_answered), so nothing pipelines. The fixture lacks the structure the hazard needs.\n\nA candidate fix, checked: await whenever any awaited frame is in flight, not only when this frame writes, i.e. `match writes || state.in_flight.iter().any(|sent| sent.awaited)`. With it, both probes' seed halves pass and the workspace stays at 1950 passed, 0 failed. protocol.md §3's \"Nothing changed\" argument and its \"never another record's number\" claim need correcting with it.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:15Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:55Z","started_at":"2026-09-23T18:29:18Z","closed_at":"2026-09-24T21:44:55Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3kfc","title":"ClientState::refused never settles the unawaited entries it leaves at the front of in_flight, so the next answer pops the wrong entry (#246 relays make it reachable)","description":"Found by the adversarial review of PR #246 (improvise-jpba) at 542dd9ce.\n\n`ClientState::refused` pops the front of `in_flight` and replays, but never calls `settle_sent`. So an UNAWAITED entry (a view-only frame of this client's, or since #246 a queued relay) left at the front after the pop stays there. The NEXT answer (`accepted` or `refused`) then pops that entry instead of the awaited frame it answers. The queue is off by one from then on: the answered frame stays in flight and is replayed, and a later answer settles it.\n\nPre-existing on gh/main 4502ed2b for this client's own view-only frames. Measured: [F1 awaited, U = SetSelected(1,1) unawaited, F2 awaited], all three sent after another client's AddItem made F1 and F2 stale. Both are refused. The second refusal pops U, not F2. Client ends at (3,1), which is refused F2's placement. The mirror is at (1,1). F2 stays in `in_flight`.\n\n#246 makes it reachable from ANY relay, because a relay heard while a frame is in flight is queued as an unawaited `Sent { relayed: true }` behind the head. Measured at 542dd9ce with a real Host and ClientState:\n- Two in-flight Model frames F1 and F2. F2 ends with SetSelected(3,1).\n- Another client's reframe (AddCategory) relays ResetCursor while both are in flight. Queue: [F1, R, F2].\n- F1 is refused (LayoutMoved). Queue: [R, F2]. R is not settled.\n- F2 is refused. It pops R, which DROPS the relay jpba exists to keep. Queue: [F2].\n- Client view at (3,1), settled at (1,0), mirror at (0,0). F2's refused commands are still queued, and the next accepted answer will settle them.\n\nOn gh/main the same relay scenario also diverges (the relay is not recorded), but the queue stays aligned. On #246 it does not.\n\nCandidate fix, verified: add `self.settle_sent();` right after `self.in_flight.pop_front();` in `refused`. With it, the probe passes, and so does every workspace test at 542dd9ce. Suggested regression test: the two-in-flight probe above (host.rs tests, `exchange`/`connected_client` shape), asserting that client.view.cursor() == the mirror's and that in_flight is empty.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:34Z","created_by":"agent-review-221c-246","updated_at":"2026-09-24T21:46:03Z","closed_at":"2026-09-24T21:46:03Z","close_reason":"Landed in main via #246 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-246"],"dependencies":[{"issue_id":"improvise-3kfc","depends_on_id":"improvise-jpba","type":"related","created_at":"2026-09-23T17:59:39Z","created_by":"agent-review-221c-246","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1be2","title":"check-ci-graph counts a --ci step whose run: is a plain multi-line scalar: a continuation line (|| true, ; exit 0) is appended to the call and never read","description":"At PR #228 head d6706774, ci_steps records a step's run: from its FIRST physical line only; the owner rule then skips deeper-indented continuation lines as content without disqualifying the step. YAML folds a plain scalar's continuation into the value: yq-go reads 'run: nix shell nixpkgs#trealla --command ./scripts/checkers.sh --ci kb' + next line ' || true' as '... --ci kb || true'. Planted on the real ci.yml, python3 scripts/check-ci-graph.py exits 0 ('all 9 checkers.sh suites run in CI') while every kb-row suite failure is swallowed. Same with '; exit 0'. The existing case 'a plain continuation line does not count' is the mirror (call on the continuation line); the call-first shape has no case. Fix: a step whose run: key owns any non-empty continuation line is not counted (report it). Also: _unrun's text says 'add it to ci.yml' but under the table design the fix is the ci_suites row in checkers.sh; the 228x245 composition prints that wrong advice.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:13Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:44:43Z","started_at":"2026-09-23T18:02:23Z","closed_at":"2026-09-24T21:44:43Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yb4b","title":"#239's spanning-fold totals ~double the TUI keypress path (paid twice per key); at 4.8k cells a held key stops keeping up, and h6z1 as filed does not reach stored-measure totals","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. Measured on an Apple M4 Max (16 cores, 64 GB) under shared load, with the\n`grid_projection` bench, min-of-N, alternating before and after, two runs each.\nMid-size cases were added locally.\n\nThe TUI pays `compute_col_widths` in `App::cmd_context` (for `visible_cols`) AND\na whole-grid `grid_of` in `draw`, on EVERY keypress, a pure cursor move\nincluded. The event loop draws once per key and does not coalesce. Since\n#239 each of them also evaluates a spanning fold per column. That is\n`evaluate_spanning` over every drawn row, which re-derives every member key and\nre-reads the store. So the per-keypress cost roughly doubles:\n\n| cells | per keypress before (gh/main 5932ad74) | after (9948aaf5) |\n|---|---|---|\n| 400 | ~1.3 ms | ~2.1 ms |\n| 1.2k | ~5.0 ms | ~8.2 ms |\n| 2.4k | ~14.6 ms | ~25.1 ms |\n| 4.8k | ~31.4 ms | ~52.4 ms |\n| 18k | ~200 ms | ~330 ms |\n| 90k | ~2.7 s | ~4.7 s |\n\n(col_widths + whole-grid, for example 18k: 78-91 → 135-157 ms, plus\n118-122 → 185-207 ms.) The user-visible line is key repeat, about 30 Hz, or\n33 ms. At 4.8k cells the TUI kept up with a held arrow key before (31 ms) and\nfalls behind after (52 ms): the cursor keeps moving after the key is released.\nThe threshold moves from about 5k cells to about 3k on the fastest hardware\navailable here. The browser pays the same `grid_in` on every scroll step and\nevery model change.\n\n**improvise-h6z1 as filed does not reach this.** Its memo is keyed by (fold\nkey, formula), and the bench's columns are stored measures under Sum, with no\nformulas. The added cost is `fold_stored`/`evaluate_unfolded` per member, which\nno formula memo touches. Levers that do reach it:\n\n- a per-grid-build value memo keyed by cell key and shared between the walk and\n the totals, since the spanning fold's members ARE the walk's cell keys in a\n dense grid;\n- computing the total once per frame rather than twice. `cmd_context` and\n `draw` each run it;\n- (argued against by design-principles §5, \"a derivation done twice\") combining\n the walk's row values for decomposable aggregators (Sum, Count, Min, Max),\n with the spanning fold reserved for Avg, formulas and text. If this is\n chosen it needs a property test against `evaluate_spanning`.\n\nFor comparison, the largest previously accepted keypress cost is gotcha 24:\n+2.65 ms at 18k (3.04 → 5.69 ms), with 56 ms at 90k called \"3.4 frames\".\nThis one is +20 ms at 4.8k and +130 ms at 18k.\n","notes":"USER RULING 2026-09-23: fix the speed first. #239 is held until the totals stop roughly doubling keypress cost.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:05Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:07Z","started_at":"2026-09-23T23:57:48Z","closed_at":"2026-09-24T21:45:07Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","labels":["adversarial-review","performance","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b5dp","title":"wm20 rulings (1) and (2) disagree for a formula column: a row's div/0 is drawn in the column and the total under it is a clean pooled number","description":"Found in adversarial review of PR #239 (improvise-wm20), head 9948aaf5. A ruling conflict that needs the user, not a code bug.\n\nThe wm20 notes record ruling (2) as: \"An error in any row propagates, so the\ntotal is an error.\" PR #239 implements it only for STORED values. For a formula\ncolumn it deliberately does not, and says so in `context/code/command.md`: \"a\nrow's div/0 is not an input and does not propagate. Only the fold's own error\ndoes.\"\n\nMeasured at 9948aaf5. Region rows North, South and East, with Product on None\nand `Profit = Revenue - Cost`, `Margin = Profit / Revenue`. East has Revenue 0.\nThe rows draw `0.83`, `0.90` and `ERR:div/0`. The total draws **`0.80`**, not an\nerror. The same happens for `Share = Revenue / SUM(Revenue)`.\n\nThis is what ruling (1) implies. \"The column's cell as if every drawn row were\none fold\" is 48/60 = 0.80: collapsing every row into one group reads 0.80 too. So\nrulings (1) and (2) disagree for formula columns, and the PR chose (1) without\nthe user deciding. A user sees an error in the column and a clean total under\nit.\n\nAsk the user which one wins for a formula column. If (2) wins, the total must be\nthe error whenever any drawn row's formula errors, which is an extra per-row\nevaluation that the pooled fold does not do.\n","notes":"USER RULING 2026-09-23: pooled ratio. A formula total is evaluated over the pooled inputs, so a per-row formula error doesn't poison it; stored-value errors still propagate.","status":"closed","priority":2,"issue_type":"task","assignee":"agent-yb4b","owner":"el-github@elangley.org","created_at":"2026-09-23T16:17:04Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:06Z","started_at":"2026-09-23T23:57:49Z","closed_at":"2026-09-24T21:45:06Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","labels":["adversarial-review","needs-user-ruling","pr-239"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-53zz","title":"The shipped-documents scratch-repo tests inherit the caller's repository env and global config, so under a worktree hook they stage files into the real index","description":"Found reviewing PR #243 (improvise-c0aj) at head 228f130e.\n\ntests/shipped_documents_load.rs shells out to git with the caller's environment and global config. Two consequences, both measured:\n\n1. **An inherited GIT_DIR writes into another repository's index.** Git exports GIT_DIR and an absolute GIT_INDEX_FILE to hooks when run from a linked worktree (measured: a pre-commit hook in a `git worktree add` checkout sees GIT_DIR=.../.git/worktrees/wt and GIT_INDEX_FILE=.../worktrees/wt/index; a normal checkout exports only a relative GIT_INDEX_FILE, pre-push exports neither, rebase -x exports neither). Every wave agent works in a linked worktree. With GIT_DIR set, `git -C \u003cscratch\u003e init` re-initialises the outer repo and `git -C \u003cscratch\u003e add .gitignore web/demo.improv` stages the scratch files into the OUTER index, because GIT_DIR wins over -C discovery and the worktree defaults to cwd. Measured: `GIT_DIR=\u003cthrowaway\u003e/.git cargo test --test shipped_documents_load` left the throwaway index holding `.gitignore`, `docs/model.improv.gz`, `secret/forced.improv`, `web/demo.improv`, and three of the four shape tests still reported ok. A pre-commit hook that runs cargo test from a worktree would therefore replace the commit's root .gitignore with `*.improv\\n!web/demo.improv\\n` and add three junk files. Latent: no hook in this repo runs cargo test today.\n\n2. **A global core.excludesFile fails a shape test falsely.** With GIT_CONFIG_GLOBAL pointing at a config whose excludesFile contains `*.gz` (a common global ignore), `a_gzipped_document_is_walked` fails at `git add` (\"paths are ignored by one of your .gitignore files\"). commit.gpgsign and core.hooksPath do not matter: no test commits, and add fires no hooks.\n\nFix: in run_git and shipped_documents, remove the repository-local env vars (`git rev-parse --local-env-vars`: GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, GIT_OBJECT_DIRECTORY, GIT_COMMON_DIR, ...). For the scratch repos also set GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_NOSYSTEM=1. Rival: re-run with GIT_DIR pointing at a throwaway repo and assert its index stays empty.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T16:03:57Z","created_by":"agent-review-243","updated_at":"2026-09-24T21:45:00Z","started_at":"2026-09-23T18:02:09Z","closed_at":"2026-09-24T21:45:00Z","close_reason":"Landed in main via #248 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3uzg","title":"Under prune_empty, a commit that prunes its own row reopens the browser editor on the renumbered neighbour's stale cache value (#227 regression)","description":"#227 (improvise-fy9o, head a093d126) regression. Found by the adversarial review of the 3tt4 fix.\n\n`resolve::advanced` seeds the reopened editor from the client's pre-write cache (`seed_at`) everywhere except the cursor's own cell. `landed_seed`'s doc says: \"The cache is stale only where the frame just wrote, which is the cursor's own cell\". That is false under `prune_empty`. A commit that empties the last value in a row (or a column) prunes that row. The grid renumbers, so the cell the advance lands on is a different cell from the one the cache holds at that position. The TUI's `CommitAndAdvance` puts `RebuildLayout` in front of `EnterEditAtCursor` (gotcha 23), so the TUI seeds from the renumbered grid.\n\nRepro (lockstep harness): three regions North/South/West = 1/2/3, one measure, `prune_empty = true`, cursor (0,0), window (3,3).\n- `i Backspace Enter`: the browser buffer is \"2\" (South, the stale cache at row 1). The host buffer is \"3\" (West, which row 1 now is). Both cursors are at (1,0).\n- Then a projection arrives: a fresh cache, with the client's ViewState kept. `5 Enter` then leaves the browser document at West=25 and the host at West=35. The browser writes a value built from another record's number.\n- At gh/main 5932ad74 both ends agree (buffer \"\" on both, West=5 on both). This PR introduces the divergence. Before fy9o, the welded clear-buffer blanked both seeds.\n\nThe same mechanism applies to a column pruned by Tab. That case was not run.\n\nSame harm shape as improvise-3tt4: \"the reopened editor shows the next cell's current value\" fails wherever the write reshapes the grid. A client cannot tell from its cache that a write will prune. So the fix probably cannot be a smarter `seed_at`. Options: defer the seed to the projection (`settle_editor` already seeds an absent buffer), or seed blank whenever the frame writes.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:45Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:45Z","labels":["adversarial-review","browser","pr-227"],"dependencies":[{"issue_id":"improvise-3uzg","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-st8q","title":"check-ci-graph fails open on a bare checkers.sh line inside a heredoc, multi-line string, or folded/plain-multiline run: scalar","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py reads a run: value one physical line at a time. It does not track a multi-line quoted string or a heredoc body, and it does not apply YAML's folded (\u003e) or plain-multiline scalar rules. So a bare `./scripts/checkers.sh` on a line of its own reads as a bare invocation, which checkers.sh --would-run answers as EVERY suite. A suite that no step runs then passes, silently. This is the same fail-open class as improvise-maax item 1 (a bare mention reads as every suite runs), reached through a different mechanism.\n\nDemonstrated against the real ci.yml, with a planted suite zz-new (scripts/zz-new plus zz-new-test.sh) that no step runs. The gate exits 1 on the plant alone. It exits 0 after either of these edits:\n1. A step-summary heredoc (a realistic step):\n run: |\n cat \u003e\u003e \"$GITHUB_STEP_SUMMARY\" \u003c\u003c'EOF'\n To reproduce locally, run\n ./scripts/checkers.sh\n from the repository root.\n EOF\n2. Folding the kb job's last step as `run: \u003e` with `./scripts/checkers.sh` on one line and `pr-watch release-preflight pr-reviews fmt-workspaces` on the next. yq confirms that the YAML value is `./scripts/checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces`, so only four suites run.\n\nThe same result comes from a multi-line double-quoted string that contains the bare line, and from a plain multi-line scalar (`run: ./scripts/checkers.sh` followed by an indented `kb`). A third mechanism gives the same result: `\"--command\" in words[k:]` matches any command, so `echo use --command ./scripts/checkers.sh` also reads as every suite.\n\nThe claim in context/code/tooling.md, \"A line shlex cannot parse contributes nothing, which fails closed\", is true of that one line only. The following lines are then misread. The same paragraph also says \"a CI step that spells it anywhere is a usage error rather than a green step that ran nothing\". That is false for the first position: `checkers.sh --would-run kb` in a step exits 0 and runs nothing. The gate still catches it, because asking `--would-run --would-run kb` is exit 4, so nothing is counted. But the step itself is green.\n\nFix direction (fail closed): tokenise the whole script rather than each line, so quotes span lines. Contribute nothing from a heredoc body, or refuse a script that contains `\u003c\u003c`. Fold `\u003e` and plain-multiline values the way YAML does. Honour `--command` only when the command word is `nix`. Add each shape as a SUITE_SELF_TEST case that requires a suite run only through the shape.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:47Z","created_by":"agent-review-228b","updated_at":"2026-09-25T00:59:37Z","started_at":"2026-09-23T15:48:08Z","closed_at":"2026-09-25T00:59:37Z","close_reason":"Landed in #228 (d6706774 on gh/main: 'CI runs checkers.sh --ci \u003cjob\u003e; the gate reads a table, not shell')","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jpba","title":"A client refusal replays from a settled slice that never folded the host's relays: after a reframe, the next refused frame splits the client cursor from its mirror","description":"Found while adversarially reviewing PR #230 (improvise-wdmp) at head 93c455db. It is pre-existing: reproduced at gh/main 194d8f52. It is not caused by #230.\n\n`ClientState::refused` rolls back by `replay()`: `view = settled.clone()`, then re-folding every in-flight frame. But `settled` only ever absorbs the client's OWN frames, in `accepted()`. A command the host RELAYS to the asker (`ResetCursor` from `adopt_cursor_epoch`, the per-client `SwitchView` from `adopt_deleted_views`, and now #230's echoed placements) is folded into `view` only. The next refusal therefore rewinds the client past every relay it has heard, while the host's mirror keeps them.\n\nReproduction, as a host test in ui/host.rs with a real `Host` and a real `ClientState` (it folds every frame addressed to the asker):\n1. The asker sends `[SetSelected(1,0)]`. It is accepted, and client, settled and mirror are all at (1,0).\n2. The asker sends `[ChangeMode(Normal), AddCategory(\"Product\")]`. The mirror is reset to (0,0) and `ResetCursor` is relayed, so the client view is (0,0). `settled` stays at (1,0).\n3. The asker sends an `AddCategory` whose basis was taken before step 2. It is refused (stale layout), and `refused()` calls `replay()`.\nResult: the client is at (1,0) and its mirror at (0,0). This is the client/mirror cursor split improvise-wdmp closes, reopened by any later refusal. With #230, the placed case also leaves `settled`'s row/col offsets un-reset: `[SetRowOffset(1), AddCategory, SetSelected]` gives settled row_offset 1 and mirror 0.\n\nThe likely fix: relayed commands must reach `settled` too. For example, `fold` for a server-originated command also reduces into `settled`, or replay composes relays in arrival order. Acceptance is the test above, together with a rival that restores the current `fold`.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:18Z","created_by":"agent-review-227-230","updated_at":"2026-09-24T21:45:13Z","started_at":"2026-09-23T15:34:15Z","closed_at":"2026-09-24T21:45:13Z","close_reason":"Landed in main via #246 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-230"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-maax","title":"check-ci-graph suites_run fails open: non-command mentions, --list, and --self-test's discarded names read as suites run","description":"Found in adversarial review of PR #228 (head cee61b94). `scripts/check-ci-graph.py::suites_run` fails OPEN on four shapes, each planted and run against `suite_verdict([\"kb\",\"zz-new\",\"checkers\"], suites_run(text))` with `zz-new` run by no step:\n\n1. A bare `scripts/checkers.sh` in text that is not a command reads as \"every suite runs\" (verdict []): an inline comment `run: echo skipped # ./scripts/checkers.sh` (ChatGPT Codex's P1 on the PR), a step name `- name: Runs scripts/checkers.sh`, and `run: echo scripts/checkers.sh`. Only a line whose FIRST non-space char is `#` is skipped.\n2. `checkers.sh --list` alone returns ALL, but `--list` prints and exits without running anything (checkers.sh main: `--list) suites; return 0`).\n3. `checkers.sh --self-test zz-new kb` is counted as running checkers, zz-new and kb (verdict []), but checkers.sh sets `names=(checkers)` when --self-test is given and DISCARDS the names. Consolidating the CI steps into one `--self-test kb pr-watch ...` line would silently stop running every named suite with the gate green.\n4. `checkers.sh --list kb zz-new` counts kb and zz-new as run; nothing runs.\n\nAlso (fails closed, loud but wrong): only the first invocation on a line counts (`./scripts/checkers.sh kb \u0026\u0026 ./scripts/checkers.sh zz-new` reports zz-new missing), and quoted names (`'zz-new'`) are read with their quotes.\n\nFix direction: model checkers.sh's own argv semantics (--list =\u003e runs nothing, --self-test =\u003e {checkers} only whatever else is named, -h/unknown flag =\u003e runs nothing) instead of \"no positional names =\u003e ALL\"; strip a shell comment before matching; only look inside `run:` values. Each shape above should become a SUITE_SELF_TEST case.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:35Z","created_by":"agent-review-228-229","updated_at":"2026-09-25T00:59:39Z","started_at":"2026-09-23T15:27:36Z","closed_at":"2026-09-25T00:59:39Z","close_reason":"Landed in #228 (c1417359 on gh/main: check-ci-graph asks checkers.sh what a step runs)","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-znuk","title":"A pivot client's first write into an empty cell is RecordGone when the host sits on a records view","description":"Found by agent-collapse-fu while scoping improvise-8id2. Creating an EMPTY cell resolves its record only through the host's layout (record_in_layout). So when the host is on a records view and a pivot client writes into an empty pivot cell, the record doesn't resolve and the write is refused RecordGone. Two clients on different views of one document is supported (gotcha 29), so this is a real multi-client failure. Resolve an empty-cell write against the view the CLIENT was shown (the view the command names), not the host's. Pin it with a Host-level test with the host on records and the client on pivot.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:17:41Z","created_by":"orchestrator","updated_at":"2026-09-23T15:17:41Z","dependencies":[{"issue_id":"improvise-znuk","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:05Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-milr","title":"RenameRecordCoordinate overwrites a destination record records_named does not name: a concurrent write to it is lost with no Rejection","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nModelCommand::RenameRecordCoordinate WRITES the destination record (when the new coordinate lands on an existing record, per improvise-kndq's overwrite rule) but records_named names only the source record. So the basis check never asks about the destination, and a concurrent write to it is lost silently.\n\nRepro (three_records_host: North=1, South=2, West=3, records mode, a real Host):\n1. A opens the coordinate editor on North and types \"South\".\n2. B writes South/Revenue = 999 (accepted). A folds the relay.\n3. A presses Enter: ACCEPTED. South/Revenue is now 1 (North's value). B's 999, which A was never shown when it opened the editor and never claimed a version of, is gone. No Rejection, no status.\n\nThis is the lost update ADR 001's record-level basis exists to prevent, and it is new with the wire form: before #221 the overwrite was reachable only from one TUI user's own keys, where there is no concurrent writer. improvise-kndq decides the POLICY for a collision (refuse, merge or overwrite); this ticket is narrower and stands whatever kndq decides unless kndq picks \"refuse\": a command that writes a record must name it in records_named, or the check is a force by omission (gotcha 43). The obstacle is that a client may not be able to claim the destination (it may be off-screen, unpublished, or not exist), so naming it probably means \"refuse if the destination exists and the frame's basis does not state it\", which is RecordUnstated on the destination.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:51Z","created_by":"agent-review-221","updated_at":"2026-09-24T21:44:49Z","started_at":"2026-09-23T15:13:31Z","closed_at":"2026-09-24T21:44:49Z","close_reason":"Landed in main via #251 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-221"],"dependencies":[{"issue_id":"improvise-milr","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T15:06:55Z","created_by":"agent-review-221","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-20ek","title":"A coordinate-rename retry after RecordGone renames whichever record moved under the cursor (browser, fdk1)","description":"Found by the adversarial review of PR #221 (improvise-fdk1) at ee5cba88.\n\nA browser client's coordinate-rename retry after Rejected(RecordGone) renames a DIFFERENT record: the one that has since moved under the cursor.\n\nRepro (probe in crates/improvise-wasm-client/tests/browser_round_trip.rs shape, three_records_host: North=1, South=2, West=3, records mode):\n1. A opens the editor on North's Region coordinate (row 0) and types \"Zed\".\n2. B renames North -\u003e Yak (RenameRecordCoordinate, accepted). A folds the relay: North's row is gone, so row 0 is now South.\n3. A presses Enter: correctly Rejected(RecordGone(North)).\n4. The refusal replays the settled slice, so A is editing again at (0, coordinate) with buffer \"Zed\". settle_opened sees (editing, opened=None, nothing awaited) and captures the record NOW under the cursor, South, at its current version.\n5. A presses Enter again: accepted. South is renamed to Zed. South is gone.\n\nMeasured: after the refusal, opened = South's RecordHash at version 0, cell text under the cursor \"South\", buffer \"Zed\". After the second Enter, Region=South/Revenue is None and Region=Zed/Revenue = 2.\n\nWhy it matters: context/code/clients.md (\"A retry after a refusal is a force\") argues the retry is the one-keystroke force RecordMoved promises (gotcha 43). That argument holds only when the record is still under the cursor. For RecordGone, gotcha 43 says there is nothing to force (\"force never creates structure\"), and the retry here is not a force of the refused edit at all: it applies text typed for North to South. The user did see an editor over South's row, but its buffer is the text they typed for North, so one Enter rewrites a record they never opened.\n\nCandidate fixes (not decided): keep `opened` across a refusal that named that record (so the retry is RecordGone again and the user must Esc and reopen); or clear the buffer/leave editing on RecordGone; or capture only when the cursor's record equals the refused one. Any fix must keep a_second_rename_after_a_reorder_renames_the_row_the_host_shows green, which pins the opposite capture after an ACCEPTED rename (an injected \"never clear opened\" rival makes this probe pass and that test fail, so the two pull in different directions).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:49Z","created_by":"agent-review-221","updated_at":"2026-09-24T21:44:48Z","started_at":"2026-09-23T15:13:29Z","closed_at":"2026-09-24T21:44:48Z","close_reason":"Landed in main via #251 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-221"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pc3b","title":"Effect-classification Debug fingerprint is order-sensitive over formula_cache (latent flake, PR #224)","description":"Found by the adversarial review of PR #224 (improvise-tt22), head e62a289c.\n\nPR #224 moved model_fingerprint (crates/improvise-command/src/ui/effect.rs tests) from serde_json to format!(\"{:?}\", model). Debug renders Model::formula_cache, a HashMap\u003cCellKey, CellValue\u003e the serde form skipped (#[serde(skip)]). recompute_formulas clears and refills it, and the refilled map iterates in a different order.\n\nDemonstrated: a temporary test built a workbook with 8 regions x Revenue/Cost and two formulas (Profit, Margin), then applied RebuildLayout, a correct ViewOnly effect that calls recompute_formulas. The Debug fingerprint differed before/after in 90/200 and 82/200 runs. On every differing run, everything up to \"formula_cache\" was byte-identical, and the formula_cache text was a character permutation of itself. So the difference is iteration order only, not a value.\n\nToday's suite is green only because test_app has no formulas. The first formula-bearing fixture added to view_only_effects_leave_shared_state_alone fails about 45% of runs on a correct effect. context/code/formula-and-io.md says the Debug fingerprint is \"no weaker\" and renders the derived cache \"the JSON skipped\" as if that were a gain. It is order-sensitive where the serde form was not.\n\nFix: fingerprint an order-independent rendering. Either exclude the derived cache (it is recomputed, not shared state a ViewOnly effect could write) or sort it. Then correct the context paragraph. Evidence belongs keyed by the new test name.\n\nThe fault-detection half still works: injecting a measure_agg write into RebuildLayout fails view_only_effects_leave_shared_state_alone with \"RebuildLayout declares ViewOnly but changed model data\".\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:10Z","created_by":"agent-review-224","updated_at":"2026-09-23T15:41:36Z","started_at":"2026-09-23T15:13:17Z","closed_at":"2026-09-23T15:41:36Z","close_reason":"Fixed in PR #224 (legacy JSON removed, user direction), merged after adversarial review (e62a289, then the delta at 38ec606). Follow-ups: improvise-djcn, improvise-zs2r.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5mqm","title":"A drill commit on a record whose ITEM was removed mid-drill writes an orphan cell that no grid shows but every aggregate counts","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. qz9p's check is category-level only: RecordRewrite::of refuses a row naming a removed CATEGORY, but Model::remove_item also sweeps the cells naming the item (core.md §9), and a drill's snapshot still names it. Probe: two_record_drill(); workbook.remove_item(\"Type\",\"Clothing\"); stage row 1 Cost=999; ApplyAndClearDrill. Result: refused=false, Type items = [Food], store gains Clothing/Jan/Cost = 999. evaluate_aggregated({Month=Jan,_Measure=Cost}, OverHidden) returns 1001 (2 + 999): the cell is invisible in any grid that enumerates Type's items but inflates every total over Type. Silent. Same shape when a category is removed and re-added under the same name (probe: remove_category(Type); add_category(Type); measure edit writes Clothing/Jan/Cost with Type having no items; qz9p's check passes because the category exists). Pre-existing on gh/main (no check at all there); #222 neither causes nor fixes it. The natural fix is qz9p's own rule one level down: a row whose coordinate names an item its category no longer has is unwritable (renames excluded, since a rename's AddItem legitimately creates the item). Decide whether that belongs in RecordRewrite::of or in a check the wire's SetCell shares.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T15:03:51Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:34:33Z","started_at":"2026-09-23T15:34:33Z","lease_expires_at":"2026-09-23T15:39:33Z","heartbeat_at":"2026-09-23T15:34:33Z","labels":["adversarial-review","pr-222"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5mqm","title":"A drill commit on a record whose ITEM was removed mid-drill writes an orphan cell that no grid shows but every aggregate counts","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. qz9p's check is category-level only: RecordRewrite::of refuses a row naming a removed CATEGORY, but Model::remove_item also sweeps the cells naming the item (core.md §9), and a drill's snapshot still names it. Probe: two_record_drill(); workbook.remove_item(\"Type\",\"Clothing\"); stage row 1 Cost=999; ApplyAndClearDrill. Result: refused=false, Type items = [Food], store gains Clothing/Jan/Cost = 999. evaluate_aggregated({Month=Jan,_Measure=Cost}, OverHidden) returns 1001 (2 + 999): the cell is invisible in any grid that enumerates Type's items but inflates every total over Type. Silent. Same shape when a category is removed and re-added under the same name (probe: remove_category(Type); add_category(Type); measure edit writes Clothing/Jan/Cost with Type having no items; qz9p's check passes because the category exists). Pre-existing on gh/main (no check at all there); #222 neither causes nor fixes it. The natural fix is qz9p's own rule one level down: a row whose coordinate names an item its category no longer has is unwritable (renames excluded, since a rename's AddItem legitimately creates the item). Decide whether that belongs in RecordRewrite::of or in a check the wire's SetCell shares.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:03:51Z","created_by":"agent-review-222","updated_at":"2026-09-25T00:59:54Z","labels":["adversarial-review","pr-222"],"dependencies":[{"issue_id":"improvise-5mqm","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:22Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"4dde6b51-f447-5998-98b3-a97ba870d17c","issue_id":"improvise-5mqm","author":"Edward Langley","text":"Returned to open during stale in-progress cleanup (2026-09-25). Work exists but never became a PR: branch gh/fix/5mqm-removed-item (b5cae686 fix, 3b15640a docs), not merged to gh/main. Next agent should pick it up from that branch, merge gh/main, and open a PR.","created_at":"2026-09-25T00:59:42Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-465t","title":"The touched reset on an editor reopened by commit-and-advance is unpinned on both front ends (a stale touched survives the whole suite)","description":"Found re-reviewing PR #205 at 7aff3e77. OpenedEditor.touched (improvise-2054) is reset to false on every editor OPEN, but nothing pins the reset on the reopen that follows a commit-and-advance, on either front end:\n\n- TUI: effect::EnterEditAtCursor sets OpenedEditor::on(value). Injection: carry the previous editor's `touched` into the new one. The full workspace stays green: 1869 pass, 0 fail.\n- Browser: resolve::advanced -\u003e opened_on(seed_at(..)). Injection: push a SetOpenedEditor carrying the previous `touched` after it. The full workspace stays green: 1870 pass, 0 fail.\n\nHarm under the injection (a scratch TUI test, run and then deleted):\n1. On grid_holding with West/Revenue empty, open `i` on (1,0).\n2. Backspace, then `3`, so the editor is touched.\n3. Tab: this commits and reopens on (1,1) with seed \"\".\n4. Another write sets West/Revenue to 5.\n5. Enter with nothing typed.\n\nAt the head West/Revenue stays 5. Under the injection it is CLEARED: the stale touched makes \"\" a write. This is improvise-ixxx's own bug coming back on the advance path.\n\nIt matters more once improvise-fy9o lands. Today the welded clear-buffer blanks the reopened editor, so a non-empty seed already writes \"\" (that is fy9o). After fy9o, the reopened buffer holds the seed. A stale touched would then make every untouched Tab/Enter through a column write its seed over concurrent edits, which is the records-mode bulk-entry path.\n\nAcceptance: one test per front end that pins the reset. Open, touch, commit and advance, then have a concurrent write land on the new cell. An untouched Enter must leave that write alone. Each test must fail under the injection above.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:03:16Z","created_by":"agent-review-205b","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T15:13:19Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","labels":["adversarial-review","pr-205"],"dependencies":[{"issue_id":"improvise-465t","depends_on_id":"improvise-2054","type":"discovered-from","created_at":"2026-09-23T15:03:23Z","created_by":"agent-review-205b","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-jmti","title":"An advance reopens the cell editor on a formula-owned or aggregate cell that edit-or-drill refuses","description":"Found on improvise-fy9o. CommitAndAdvance ends in EnterEditAtCursor, which opens editing on whatever cell the advance lands on; EditOrDrill refuses formula-owned cells ('edit the formula, not the cell') and drills aggregates. Measured: 2x2 pivot plus formula Profit = Revenue - Cost, i on (West,Revenue), Tab: cursor (1,2) on Profit, mode Editing, buffer \"\". With fy9o's untouched rule an Enter there writes nothing, but typing writes a raw cell under a formula target (TUI) or composes nothing and goes Upstream (browser: typed_cell_command returns None for CellId::Derived, so resolve::commit returns None, a black-hole key). The re-entry should apply EditOrDrill's refusals (or stop in Normal) on both ends, line for line. Acceptance: TUI and browser tests that advance onto a formula cell and onto an aggregate and assert the editor does not reopen there (or reopens with the refusal status).","notes":"PR #277 (improvise-enz6) meets jmti's acceptance on both ends (fold + formula entry declined in TUI and browser). Close both when #277 merges.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:54Z","created_by":"agent-ixxx","updated_at":"2026-09-24T01:03:21Z","started_at":"2026-09-23T15:53:53Z","lease_expires_at":"2026-09-23T15:58:53Z","heartbeat_at":"2026-09-23T15:53:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jmti","title":"An advance reopens the cell editor on a formula-owned or aggregate cell that edit-or-drill refuses","description":"Found on improvise-fy9o. CommitAndAdvance ends in EnterEditAtCursor, which opens editing on whatever cell the advance lands on; EditOrDrill refuses formula-owned cells ('edit the formula, not the cell') and drills aggregates. Measured: 2x2 pivot plus formula Profit = Revenue - Cost, i on (West,Revenue), Tab: cursor (1,2) on Profit, mode Editing, buffer \"\". With fy9o's untouched rule an Enter there writes nothing, but typing writes a raw cell under a formula target (TUI) or composes nothing and goes Upstream (browser: typed_cell_command returns None for CellId::Derived, so resolve::commit returns None, a black-hole key). The re-entry should apply EditOrDrill's refusals (or stop in Normal) on both ends, line for line. Acceptance: TUI and browser tests that advance onto a formula cell and onto an aggregate and assert the editor does not reopen there (or reopens with the refusal status).","notes":"PR #277 (improvise-enz6) meets jmti's acceptance on both ends (fold + formula entry declined in TUI and browser). Close both when #277 merges.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:54Z","created_by":"agent-ixxx","updated_at":"2026-09-24T21:45:14Z","started_at":"2026-09-23T15:53:53Z","closed_at":"2026-09-24T21:45:14Z","close_reason":"Landed in main via #277 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-sjdb","title":"Wire SetCell into an empty cell beside a collapsed _Measure group is refused: aggregate_refusal judges the record at the fold's column","description":"Found in adversarial review of PR #217 (improvise-ciw8), head bc060f67.\n\nREGRESSION introduced by #217. `App::aggregate_refusal` locates an unstored record with `record_position(hash)`, which matches on `RecordHash` — the coordinate tuple with `_Measure` dropped — and then asks `project::cell_id` at the FIRST position carrying that record. When `_Measure` is on an axis and one of its groups is collapsed, that first position can be the measure-group fold, which `cell_id` calls `Aggregate`, so a legitimate write to a different measure of the same row is refused.\n\nRepro (host test, reviewer's `r5_an_empty_cell_beside_a_measure_fold_is_writable`): Month={Jan,Feb} on Row; _Measure on Column with Revenue,Cost in group Money (collapsed) and Units ungrouped; only Jan/Revenue stored. The host projects Feb x Units as `CellId::Record`. A wire `SetCell` on it (caught-up basis) is answered `Rejected(MutationRefused)`. With `unwritable` returning None (pre-#217 behaviour) it is Accepted. In the browser: typing into an empty cell of a row beside a collapsed measure group shows a rejection.\n\nFix shape: locate by the FULL cell (record + measure), i.e. the layout position whose key equals `resolve_cell(cell)`, and ask `cell_id` there.\n\nAlso, the stored-index arm (\"a stored record is always a record\") is UNPINNED: injecting \"stored records also go through cell_id\" fails nothing in #217's suite. Pin it with the reviewer's `r6_a_stored_record_the_hosts_view_aggregates_is_writable` (South/Revenue stored, then Product added on None in the host's view: the record is legit in records mode, and under the injection it is refused).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:33Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:35:48Z","started_at":"2026-09-23T15:14:09Z","closed_at":"2026-09-23T15:35:48Z","close_reason":"Fixed in PR #217, merged as cc9bc41 after adversarial review (bc060f6, then the sjdb delta at 52fa397). Follow-ups: improvise-b114, improvise-xur5.","labels":["adversarial-review","pr-217"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wdmp","title":"The browser cursor jumps to the origin after the client's own records rename (and any reframing browser write)","description":"Found by agent-hzjo2 on improvise-fdk1 (PR #221). The host's cursor-epoch ResetCursor is broadcast to every client, the asker included, so a browser user whose own write reframes the grid (for example a records coordinate rename that re-sorts rows) lands at the origin. The TUI instead advances. This predates fdk1 and applies to any reframing browser write. tests/lockstep.rs can't see it, because its twin host has no Sessions. Decide whether the asker should keep its position (for example addressed ResetCursor excluding the asker, with the asker's own fold placing the cursor), and pin it with a Sessions-level test.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:07Z","created_by":"orchestrator","updated_at":"2026-09-23T15:58:32Z","started_at":"2026-09-23T14:55:56Z","closed_at":"2026-09-23T15:58:32Z","close_reason":"Fixed in PR #230, merged after adversarial review at 93c455d. The later changes were evidence-only merges of gh/main. Follow-ups: improvise-hv0r, improvise-jpba.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-d7aj","title":"No test pins the fold memo's key granularity: a memo keyed by formula alone passes the whole suite","description":"Found by the adversarial delta re-review of PR #203 at b3379d1a (agent-review-197-203b).\n\nThe fold memo in `Model::fold_formula_once` (crates/improvise-core/src/model/types.rs) is keyed by the full `CellKey`, which is right. **No test pins that.** I injected a rival that keys the memo by the formula's target name alone. That makes a formula reached at two different fold keys in one evaluation read the first key's value, or report `circular`. The whole workspace suite still passed: 1850+ tests, including `a_fold_evaluates_each_formula_it_reaches_once` and `a_cycle_at_a_fold_is_still_circular`. Both of those use one key context throughout, so they cannot tell the two keyings apart.\n\nThe reachable case: an `IF` whose branch refers to an item of another category. That moves the key while `_Measure` stays the same formula. Fixture: `quarter_model` with Jan/Feb × A = 3/4 and Jan/Feb × B = 100/200, and `G = Revenue * 2`, `F = IF(Revenue \u003e 50, G, G + B)`. At (Q1, A, F) the correct answer is 614: 14 for G@A plus F@B, and F@B is G@B = 600. The formula-keyed rival fails it. The test was a scratch test in the review, not committed.\n\nAlso add the diamond as a pinned case: `A1 = B2 + C3`, `B2 = D*2`, `C3 = D*3`, `D = Revenue`. It gives 35 at the fold and must not be `circular`. It passes today, and nothing pins it.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:54:17Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:35:50Z","started_at":"2026-09-23T15:14:08Z","closed_at":"2026-09-23T15:35:50Z","close_reason":"Fixed in PR #232 (tests only), merged as bb5dfe5 after adversarial review at 993418f.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -137,29 +215,29 @@ {"_type":"issue","id":"improvise-tt22","title":"Remove the legacy JSON document format: .improv is the only native format","description":"USER DIRECTION 2026-09-23, on PR #210: 'The only supported native file format is .improv, cleanup any legacy JSON format.' Today persistence auto-detects a leading '{' and loads legacy JSON (read-only). Remove that load path and its serde derives where they exist only for it, along with its tests and docs (repo-map .improv section, gotcha mentions). JSON *import* (import wizard / ImportJsonHeadless) is a different feature and stays. Decide what loading a legacy-JSON file does afterwards: a clear error naming the format, not a parse error. Independent of ADR 007, but it removes the legacy-JSON id cost (improvise-65kf).","status":"closed","priority":2,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:40:02Z","created_by":"orchestrator","updated_at":"2026-09-23T15:41:36Z","started_at":"2026-09-23T14:41:47Z","closed_at":"2026-09-23T15:41:36Z","close_reason":"Fixed in PR #224 (legacy JSON removed, user direction), merged after adversarial review (e62a289, then the delta at 38ec606). Follow-ups: improvise-djcn, improvise-zs2r.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ugj4","title":"A click-edit opened from a stale paint shows old text but captures the current seed and version, so an untouched Enter overwrites the newer value","description":"Found merging #188 into #205. web/app.js repaint() returns early while an editor is open, so frames that arrive then change the cache but not the DOM. A click on another cell closes the old editor (no repaint) and calls edit(td, row, col, action.editText): the \u003cinput\u003e is seeded from the td's PAINTED edit text, while client.edit_target(row,col) captures EditTarget { version, opened.seed } from the CURRENT cache. Enter with nothing typed: input.value (stale) != opened.seed (current) so untouched() says touched, and typed_target_frame composes a write of the stale text claimed at the CURRENT version, which the host accepts: another client's value is overwritten silently. Same mismatch affects #188/0j0f's version capture (version is newer than the text shown). Fix direction: seed the \u003cinput\u003e from what the target captured (e.g. edit_target returns the seed, or the page repaints the cell before opening), so what is shown, what is compared and what is claimed are one capture. Test: Playwright, open editor A, deliver a frame changing B, click B, Enter untouched: no SetCell posted.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:40:54Z","created_by":"agent-ixxx","updated_at":"2026-09-23T17:52:53Z","started_at":"2026-09-23T15:30:50Z","closed_at":"2026-09-23T17:52:53Z","close_reason":"Fixed in PR #240, merged as 0ff17c0 after adversarial review at 35013b6.","comments":[{"id":"65886fbc-05f9-5819-b7c0-a02f893b4583","issue_id":"improvise-ugj4","author":"agent-review-205b","text":"Reproduced end to end at PR #205 head 7aff3e77 by agent-review-205b. The rig was a real improvise-ws-server on web/demo.improv with two Chromium pages. A opened an editor on cell 0. B wrote 777 into cell 4. A's paint stayed 9,100.00, because repaint() early-returns while the editor is open. A clicked cell 4, and the input was seeded 9100. A pressed Enter with nothing typed and sent SetCell 9100 with basis records [[rec,1]], which is the CURRENT version. The host accepted it, and B's 777 was overwritten silently. This PR does not make it worse than gh/main: gh/main's typed_target_frame writes unconditionally at the captured version. On this input the stale text differs from the current seed by construction, so untouched() never fires and the path is identical. Pre-#188 main recomputed the target at commit, so it too claimed the current version. The bug predates both PRs.","created_at":"2026-09-23T15:03:35Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-65kf","title":"ADR 005 clause 9: '#[serde(transparent)]' cannot load legacy JSON's numeric ids if Q1's string ids are chosen","description":"Raised by the ChatGPT Codex review of PR #210 (ADR 005, head 9d97664c), and confirmed by the adversarial review.\n\nClause 9 step 1 says legacy JSON \"still deserializes, through `#[serde(transparent)]`\". Open question 1 recommends that ids be \"a small string internally\" so that a hand-written `(default)` round-trips. These cannot both hold. Legacy JSON is a serde-derived `Workbook`, and `Category.id`/`Item.id` are `usize` aliases today, so the files carry `\"id\": 3` as a JSON number. A `#[serde(transparent)]` newtype over a `String` rejects a number, and a numeric newtype cannot hold a word id.\n\nThe ADR must specify a representation, or a custom `Deserialize`, that accepts both the legacy numeric form and the textual form, and say how it is written (legacy JSON is never written, so possibly only for the wire). It must also require a legacy-JSON load test with numeric ids. Legacy JSON also carries `Model::next_category_id`, which the rectifying pass has to ignore or accept.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:37:14Z","created_by":"agent-review-210","updated_at":"2026-09-23T15:41:36Z","closed_at":"2026-09-23T15:41:36Z","close_reason":"Fixed in PR #224 (legacy JSON removed, user direction), merged after adversarial review (e62a289, then the delta at 38ec606). Follow-ups: improvise-djcn, improvise-zs2r.","dependencies":[{"issue_id":"improvise-65kf","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:37:14Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i0n7","title":"ADR 005: _Measure's formula-target rows have no ItemId for CategoryRow, and ViewState's expanded_cats and view history are name-keyed too","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). Two places where the ADR's projection and `ViewState` clauses do not match the code at 4b880846.\n\n1. **`CategoryRow.items` cannot become `(ItemId, String)` for `_Measure`.** `project.rs::category_rows` fills `items` from `Model::effective_item_names`, which for `_Measure` is the explicit items plus the formula targets (gotcha 7). Clause 1 says a formula target in `_Measure` \"has no item of its own to carry\" an id. So the clause 6 shape has no id to put in those rows. The category panel does act on them today: `resolve.rs::delete_at_cat_cursor` composes `RemoveItem { category, item }` for any item row, formula targets included, and fz77's rename will too. The ADR must say what a formula-target row carries (its `FormulaId`? nothing?) and what remove or rename means on it.\n\n2. **`ViewState` holds more name-keyed state than the two fields clause 6 names.** Clause 6 warns only about `active_view` and `cursors` for a future view rename. But:\n - `ViewState::expanded_cats: HashSet\u003cString\u003e` is keyed by **category** name. fz77's category rename, which this ADR unblocks now rather than later, collapses the renamed category's panel node for every client, with nothing in the stream to correct it.\n - `view_back_stack`/`view_forward_stack` hold `ViewFrame { view_name }`, which a view rename orphans exactly as it orphans `cursors`.\n\n These are per-client state the host cannot rewrite. So a rename has to be followed by a relay, like `adopt_deleted_views`, or the keys have to move to ids first. The ADR should list them, and say which applies to fz77.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:12Z","dependencies":[{"issue_id":"improvise-i0n7","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gkyo","title":"ADR 005 wire clauses: in-frame removal (ChainBroken analogue), Rejection losing Copy under string ids, create variants that address a parent, and create-then-address frames are unaddressed","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). The ADR's wire clauses (6 and 7) leave four things unsaid, each checked against the code at 4b880846.\n\n1. **An id removed earlier in the same frame.** `CheckedBatch::against` answers every command against the pre-frame `App`. So `[RemoveFormula(3), EditFormula(3)]` passes the check, since id 3 resolves before anything runs. The per-mutation structural re-check that catches this for records is `reduce.rs::broken_chain` (`Rejection::ChainBroken`), and it only walks `records_named`. The ADR adds `Rejection::Gone` to the pre-frame check and says nothing about the in-frame case. So whether the frame is refused loudly (via `MutationRefused`) or half-applied silently depends on how each new effect behaves on a missing id. The ADR should say that `broken_chain` gains an id arm, and that an exhaustive `ids_named(cmd)` (the analogue of `records_named`) is the server's decision about which ids a command names.\n\n2. **`Rejection` is `Copy`, and `Gone(Named)` breaks it under the Q1 recommendation.** `basis.rs` derives `Copy` on `Rejection`, and relies on it: the `SaveFailed` doc says it carries no message *because* `Rejection` is `Copy`. `Rejection::all(sample: RecordHash)` walks the variants with a `RecordHash` sample only. Q1 recommends that ids be \"a small string internally\", which makes `Named`, and so `Rejection`, non-`Copy`. Q1 does not list that cost. Nor does it define \"max id seen + 1\" (clause 3) when ids can be words.\n\n3. **\"Create by name\" operations also address a parent.** `AddItem { category }`, `AddItemInGroup { category, group }` and `AddFormula { target_category }` create one thing and address an existing one by name. Clause 6 files them under \"keep names\", while its own rule says a migrated variant takes ids for \"all of its addressing operands\". An `AddItem` racing a category remove-and-re-add lands on the namesake, which is the hazard the ADR exists to close. The fdk1 variant does the mixed form (category by id, item by name), so the ADR already accepts it. It should say which rule governs the create variants.\n\n4. **Create-then-address in one frame already ships.** `resolve.rs` \"create-and-switch-view\" sends `[CreateView(name), SwitchView(name)]`. Clause 7's \"no id exemption to write, and none should be\" holds only while `SwitchView` stays by name. Clause 6 requires `ViewState` keys to move to `ViewId` before any view rename. When they do, this frame cannot be composed, because the client cannot know the new view's id. \"The host assigns every id\" is therefore a decision, not a consequence. The alternative (a client proposes an id on create, as Q1's word ids would allow) is not presented.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:11Z","dependencies":[{"issue_id":"improvise-gkyo","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rxz9","title":"ADR 005's id_token uses ASCII_ALPHANUMERIC, which the grammar generator emits as nothing: 1.4% of generated files are rejected; formula ids are unreachable by the generator","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nThe ADR proposes `id_token = @{ (ASCII_ALPHANUMERIC | \"_\" | \"-\")+ }` and says that the grammar-walking generator (`persistence/parse.rs::generator`) can still produce only valid files. It cannot. `Gen::emit` knows only the built-ins `ANY`, `NEWLINE`, `SOI`, `EOI` and `ASCII_DIGIT`. It looks any other identifier up as a user rule, finds nothing, and emits nothing. So `ASCII_ALPHANUMERIC` contributes the empty string, and `id_tag` is often generated as `()`.\n\nMeasured with a scratch copy of improv.pest carrying the ADR's rules, and a copy of `Gen`: in 20,000 generated files, 1,230 contained `()` and 271 (1.4%) were rejected by the grammar, for example `- Kf__, _-_-()` (\"expected id_token\"). `generated_file_parses` would fail. In a header, `Default()` is silently accepted through `line_name` as a name containing parentheses, which is also not what the generator meant to exercise. With `id_token` spelled out as `('A'..'Z' | 'a'..'z' | '0'..'9' | \"_\" | \"-\")+`, which is what `bare_name` already does, the count drops to 0 of 20,000.\n\nA related gap: the generator cannot exercise formula ids at all. `ANY` emits only a-z, so no generated `formula_raw` contains `=`, and `walk_formulas_section` silently drops every formula line without `=` (`if raw.contains('=')`). Measured on the unchanged grammar: 36 of 2,000 generated files have a `## Formulas` line, and every one of them is dropped. The ADR's round-trip properties \"with and without ids\" are therefore vacuous for formulas under the generator, unless the generator learns to emit a formula.\n\nWhat the ADR must do: spell `id_token` with ranges, and state that the generator needs a formula production before property 3 covers formula ids.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:09Z","dependencies":[{"issue_id":"improvise-rxz9","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i0n7","title":"ADR 005: _Measure's formula-target rows have no ItemId for CategoryRow, and ViewState's expanded_cats and view history are name-keyed too","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). Two places where the ADR's projection and `ViewState` clauses do not match the code at 4b880846.\n\n1. **`CategoryRow.items` cannot become `(ItemId, String)` for `_Measure`.** `project.rs::category_rows` fills `items` from `Model::effective_item_names`, which for `_Measure` is the explicit items plus the formula targets (gotcha 7). Clause 1 says a formula target in `_Measure` \"has no item of its own to carry\" an id. So the clause 6 shape has no id to put in those rows. The category panel does act on them today: `resolve.rs::delete_at_cat_cursor` composes `RemoveItem { category, item }` for any item row, formula targets included, and fz77's rename will too. The ADR must say what a formula-target row carries (its `FormulaId`? nothing?) and what remove or rename means on it.\n\n2. **`ViewState` holds more name-keyed state than the two fields clause 6 names.** Clause 6 warns only about `active_view` and `cursors` for a future view rename. But:\n - `ViewState::expanded_cats: HashSet\u003cString\u003e` is keyed by **category** name. fz77's category rename, which this ADR unblocks now rather than later, collapses the renamed category's panel node for every client, with nothing in the stream to correct it.\n - `view_back_stack`/`view_forward_stack` hold `ViewFrame { view_name }`, which a view rename orphans exactly as it orphans `cursors`.\n\n These are per-client state the host cannot rewrite. So a rename has to be followed by a relay, like `adopt_deleted_views`, or the keys have to move to ids first. The ADR should list them, and say which applies to fz77.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","updated_at":"2026-09-23T06:36:12Z","dependencies":[{"issue_id":"improvise-i0n7","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:52Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-i0n7","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:12Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"e512507a-b700-56ab-95a6-897a9a0ba3a2","issue_id":"improvise-i0n7","author":"agent-adr007","text":"agent-adr007 planning: revision 8 answers both items: ItemHandle::FormulaTarget, and clause 6 lists expanded_cats and the view history. One remainder, what remove and rename mean on a FormulaTarget row, goes in the text fix improvise-cq1q.1.","created_at":"2026-09-25T04:51:21Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-gkyo","title":"ADR 005 wire clauses: in-frame removal (ChainBroken analogue), Rejection losing Copy under string ids, create variants that address a parent, and create-then-address frames are unaddressed","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c). The ADR's wire clauses (6 and 7) leave four things unsaid, each checked against the code at 4b880846.\n\n1. **An id removed earlier in the same frame.** `CheckedBatch::against` answers every command against the pre-frame `App`. So `[RemoveFormula(3), EditFormula(3)]` passes the check, since id 3 resolves before anything runs. The per-mutation structural re-check that catches this for records is `reduce.rs::broken_chain` (`Rejection::ChainBroken`), and it only walks `records_named`. The ADR adds `Rejection::Gone` to the pre-frame check and says nothing about the in-frame case. So whether the frame is refused loudly (via `MutationRefused`) or half-applied silently depends on how each new effect behaves on a missing id. The ADR should say that `broken_chain` gains an id arm, and that an exhaustive `ids_named(cmd)` (the analogue of `records_named`) is the server's decision about which ids a command names.\n\n2. **`Rejection` is `Copy`, and `Gone(Named)` breaks it under the Q1 recommendation.** `basis.rs` derives `Copy` on `Rejection`, and relies on it: the `SaveFailed` doc says it carries no message *because* `Rejection` is `Copy`. `Rejection::all(sample: RecordHash)` walks the variants with a `RecordHash` sample only. Q1 recommends that ids be \"a small string internally\", which makes `Named`, and so `Rejection`, non-`Copy`. Q1 does not list that cost. Nor does it define \"max id seen + 1\" (clause 3) when ids can be words.\n\n3. **\"Create by name\" operations also address a parent.** `AddItem { category }`, `AddItemInGroup { category, group }` and `AddFormula { target_category }` create one thing and address an existing one by name. Clause 6 files them under \"keep names\", while its own rule says a migrated variant takes ids for \"all of its addressing operands\". An `AddItem` racing a category remove-and-re-add lands on the namesake, which is the hazard the ADR exists to close. The fdk1 variant does the mixed form (category by id, item by name), so the ADR already accepts it. It should say which rule governs the create variants.\n\n4. **Create-then-address in one frame already ships.** `resolve.rs` \"create-and-switch-view\" sends `[CreateView(name), SwitchView(name)]`. Clause 7's \"no id exemption to write, and none should be\" holds only while `SwitchView` stays by name. Clause 6 requires `ViewState` keys to move to `ViewId` before any view rename. When they do, this frame cannot be composed, because the client cannot know the new view's id. \"The host assigns every id\" is therefore a decision, not a consequence. The alternative (a client proposes an id on create, as Q1's word ids would allow) is not presented.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","updated_at":"2026-09-25T06:35:45Z","closed_at":"2026-09-25T06:35:45Z","close_reason":"Answered by accepted ADR 007 (revision 8); verified by the PR #318 review (issuecomment-5827855684)","dependencies":[{"issue_id":"improvise-gkyo","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:51Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-gkyo","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:11Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"9b3b442b-340b-55a2-b820-fe7f8e6b80f2","issue_id":"improvise-gkyo","author":"agent-adr007","text":"agent-adr007 planning: revision 8 answers all four items. (1) Clause 7 adds ids_named and broken_chain's id arm. (2) Clause 3 records that Rejection loses Copy. (3) Clause 6 has create variants take the parent's id. (4) Clause 6 adds CreateView { display, switch }. Recommend closing. Implemented in improvise-cq1q.8.","created_at":"2026-09-25T04:51:19Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-rxz9","title":"ADR 005's id_token uses ASCII_ALPHANUMERIC, which the grammar generator emits as nothing: 1.4% of generated files are rejected; formula ids are unreachable by the generator","description":"Found by the adversarial review of PR #210 (ADR 005, head 9d97664c).\n\nThe ADR proposes `id_token = @{ (ASCII_ALPHANUMERIC | \"_\" | \"-\")+ }` and says that the grammar-walking generator (`persistence/parse.rs::generator`) can still produce only valid files. It cannot. `Gen::emit` knows only the built-ins `ANY`, `NEWLINE`, `SOI`, `EOI` and `ASCII_DIGIT`. It looks any other identifier up as a user rule, finds nothing, and emits nothing. So `ASCII_ALPHANUMERIC` contributes the empty string, and `id_tag` is often generated as `()`.\n\nMeasured with a scratch copy of improv.pest carrying the ADR's rules, and a copy of `Gen`: in 20,000 generated files, 1,230 contained `()` and 271 (1.4%) were rejected by the grammar, for example `- Kf__, _-_-()` (\"expected id_token\"). `generated_file_parses` would fail. In a header, `Default()` is silently accepted through `line_name` as a name containing parentheses, which is also not what the generator meant to exercise. With `id_token` spelled out as `('A'..'Z' | 'a'..'z' | '0'..'9' | \"_\" | \"-\")+`, which is what `bare_name` already does, the count drops to 0 of 20,000.\n\nA related gap: the generator cannot exercise formula ids at all. `ANY` emits only a-z, so no generated `formula_raw` contains `=`, and `walk_formulas_section` silently drops every formula line without `=` (`if raw.contains('=')`). Measured on the unchanged grammar: 36 of 2,000 generated files have a `## Formulas` line, and every one of them is dropped. The ADR's round-trip properties \"with and without ids\" are therefore vacuous for formulas under the generator, unless the generator learns to emit a formula.\n\nWhat the ADR must do: spell `id_token` with ranges, and state that the generator needs a formula production before property 3 covers formula ids.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","updated_at":"2026-09-25T06:35:48Z","closed_at":"2026-09-25T06:35:48Z","close_reason":"Answered by accepted ADR 007 (revision 8); verified by the PR #318 review (issuecomment-5827855684)","dependencies":[{"issue_id":"improvise-rxz9","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:52Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-rxz9","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-23T06:36:09Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"1d08f7b1-5679-58de-b671-d574074237dc","issue_id":"improvise-rxz9","author":"agent-adr007","text":"agent-adr007 planning: clause 5 now spells id_token with ranges and requires a formula-line strategy. Recommend closing as answered in the text. The implementation criterion is in improvise-cq1q.5.","created_at":"2026-09-25T04:51:20Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-jibp","title":"The mouse editor has no touched flag: a click-edit typed back to its seed over a concurrent write is still dropped silently","description":"Residue of improvise-2054, found on PR #205. The keyboard paths (TUI and browser resolve) mark OpenedEditor.touched on append-char/pop-char, so untouched() requires !touched \u0026\u0026 text == seed. The browser mouse editor (web/app.js edit(), typed_cell) edits a DOM \u003cinput\u003e Rust never sees, so typed_cell_frame builds OpenedEditor::on(seed) untouched and compares content alone. Repro: click a cell holding 7, another client writes 5, select-all and type 7, Enter: no frame is sent and the cell stays 5 with no message. Fix belongs with #188's captured EditTarget (improvise-2wu3) carrying OpenedEditor: the page reports the first input event to Rust (or passes a touched flag), and the decision stays in untouched(). Acceptance: a socket.rs test where a touched mouse commit equal to the seed composes a write, and a Playwright check that an input edit reaches it.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:32:09Z","created_by":"agent-ixxx","updated_at":"2026-09-23T17:52:53Z","started_at":"2026-09-23T15:30:51Z","closed_at":"2026-09-23T17:52:53Z","close_reason":"Fixed in PR #240, merged as 0ff17c0 after adversarial review at 35013b6.","comments":[{"id":"f27e94ea-2d4f-5ad9-a38b-cdf8a0d043ff","issue_id":"improvise-jibp","author":"agent-review-205b","text":"Confirmed end to end at PR #205 head 7aff3e77 by agent-review-205b, with a real ws-server and two Chromium pages. A click-edited cell 4 (seed 9100). B wrote 555. A cleared the input, typed 9100 and pressed Enter. A sent 0 SetCell frames, and the cell stayed 555 with no message. Note the direction: on gh/main the same keystrokes WRITE 9100, because typed_target_frame has no untouched check there. So on the mouse path this is a regression #205 introduces. It is the same trade 2054 was on the keyboard path: the common case (an untouched Enter over a concurrent write) is fixed, and the rare case (retyping exactly the old value over a concurrent write) is lost.","created_at":"2026-09-23T15:03:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-btyw","title":"Determinism width guard counts applied drill state, but the law compares Debug text: an order-eliding Debug blinds the law with the guard green","description":"Found in adversarial review of PR #208 (improvise-b9f2) at head 7dfa90f1.\n\nThe fix makes the width guard apply the compared `drill-into-cell` effects to a scratch App and count `drill_state.records`. The effects are the same objects that were compared: `left` is formatted from the Vec before it moves into `records_narrowed`. But the comparison does not compare those objects. It compares their **Debug text** (`format!(\"{effects:?}\")`). The guard counts the records in the applied state. Those are two different projections of one value. `context/code/command.md` claims \"The width guard measures what the comparison compared\", and that is false one level down.\n\nRival (run, not reasoned): give `StartDrill` a hand-written Debug that prints only the count (`StartDrill(4 rows)`) and remove `positions.sort_unstable()` in `DataStore::matching_positions` (gotcha 18). Result: the law **passes 300 of 300** and the guard never fires. The drills froze 4 records each in hash order, so every one of them differed between contexts, and the comparison could not see it.\n\nA cheap repair, verified locally: count a drill only if the compared text contains the frozen records, e.g. `.filter(|d| left.contains(\u0026format!(\"{:?}\", d.records)))` before `map_or`, passing `\u0026left` into `records_narrowed`. Measured on the M4 Max:\n- that rival plus gotcha 18: the guard fires 200 of 200;\n- clean: the law passes 200 of 200.\n\nRelated, and not a defect in #208: the drill is the law's **only** witness for gotcha 18 in the demo. With `drill_snapshot` switched to `iter_cells().filter(matches_partial)` and gotcha 18 injected, the law passes 200 of 200. That pass is correct, since no command then follows hash order. But no other command's compared text is sensitive to `matching_cells` order in this fixture.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T06:23:24Z","created_by":"agent-review-208","updated_at":"2026-09-23T14:56:22Z","started_at":"2026-09-23T06:25:44Z","closed_at":"2026-09-23T14:56:22Z","close_reason":"Fixed in PR #208, merged as bf2de49. Reviewed at 7dfa90f; the btyw delta was the reviewer's verified one-line repair, re-measured by the author.","dependencies":[{"issue_id":"improvise-btyw","depends_on_id":"improvise-b9f2","type":"discovered-from","created_at":"2026-09-23T06:23:37Z","created_by":"agent-review-208","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wv3h","title":"#205's stale-seed guards are unpinned: dropping SetOpenedEditor(None) on enter-mode (TUI or browser) leaves the workspace green","description":"Found reviewing PR #205 (improvise-ixxx), head 5df8ed2c. Two of #205's stale-seed guards have no test that can fail.\n\n(1) EnterMode's SetOpenedEditor(None) in cmd/mode.rs, pushed when entering an editing mode. Deleting it leaves the whole workspace green (1829 passed, 0 failed).\n(2) The same line in resolve.rs::entered(), the browser twin. Deleting it also leaves 1829 passed, 0 failed.\n\nGuard (1) is reachable. A scratch test (not committed) does the following: grid_holding(Text(\"007\")), cursor (1,1). Press i, then Esc; opened_editor keeps seed \"007\" in Normal mode, since nothing clears it on exit. Move to (0,1), which holds Number(2.0). Type `:enter-mode editing`, Enter, then type 007 and press Enter. At 5df8ed2c this stores Number(7.0), which is correct. With guard (1) deleted it stays Number(2.0): a typed value is swallowed by an escaped editor's seed. The test passes at 5df8ed2c and at gh/main, and fails only under the injection, so it discriminates.\n\nRelated gap: a trimmed comparison in untouched() (seed.trim() == text.trim()) also passes the whole workspace. A text cell ' x' edited to 'x' would then write nothing. No test separates exact from trimmed equality.\n\nFix: add the scratch test as a TUI regression, plus a resolve.rs twin driving `entered()`, plus one untouched() case whose seed and buffer differ only by whitespace. The cheapest place is a follow-up commit on #205 before merge.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:07:23Z","created_by":"agent-review-205","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T06:09:16Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","labels":["adversarial-review","pr-205","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2054","title":"An editor edited back to its seed writes nothing: untouched() compares content, so a deliberate re-entry over a concurrent write is silently dropped (#205)","description":"Found reviewing PR #205 (improvise-ixxx), head 5df8ed2c. untouched() judges an editor by CONTENT (buffer == seed captured at open), not by whether the user edited it. So an editor the user deliberately edited BACK to its seed writes nothing, and a concurrent write that landed while it was open wins silently.\n\nCONFIRMED with a scratch test in crates/improvise-command/src/command/cmd/commit.rs (not committed): grid_holding(Number(7.0)), cursor (1,1) = West/Revenue. Press i (seed \"7\"). Apply effect::SetCell(West/Revenue, 5.0), standing in for another client's write. Press Backspace, then 7, then Enter. At 5df8ed2c the cell stays Number(5.0), and the user who typed 7 is told nothing. At gh/main 98d10ca5 the same test stores Number(7.0). So #205 introduces this. The browser keyboard path (resolve::written) and the mouse path (typed_cell_frame) behave the same way by construction, because they share untouched().\n\nThis is the other side of #205's own an_untouched_edit_does_not_overwrite_a_write_made_while_it_was_open, which is right for a truly untouched editor. A content comparison cannot tell 'never touched' from 'touched and put back'.\n\nIt composes badly with improvise-0j0f / PR #188. Once the basis is claimed at open, a touched write over a concurrent change should come back Rejected(RecordMoved), which is loud. #205 swallows it before it is composed, so the refusal the user would have seen never happens. The TUI host has no basis check, so there the outcome flips from 'my 7 wins' to 'their 5 wins, silently'.\n\nOptions (a decision is needed):\n(a) Keep a touched flag on OpenedEditor. Any append-char or pop-char into the edit buffer sets it, and untouched() requires !touched. Both #205 tests still pass, and so does this one.\n(b) Accept content semantics and document them.\nNote: (a) lives in ViewState, so the browser's append/pop resolvers and reduce_view must set the flag too. That is one more Command, or a field SetBuffer carries.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T06:07:22Z","created_by":"agent-review-205","updated_at":"2026-09-23T15:24:59Z","started_at":"2026-09-23T06:09:19Z","closed_at":"2026-09-23T15:24:59Z","close_reason":"Fixed in PR #205, merged after adversarial re-review at 7aff3e7; the later changes were an evidence-only gh/main merge plus the two 465t tests the reviewer requested. Follow-ups: improvise-jibp, improvise-ugj4.","labels":["adversarial-review","pr-205"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sboz","title":"ADR: stable identities for categories, items and formulas (transient is acceptable)","description":"USER DIRECTION 2026-09-23: 'there would be a lot of benefit to categories/formulas having stable identities, even if they were only transient: it would help pin editing operations and distinguish them from delete/remove.'\n\nToday the name is the identity. Category.id and Item.id are usize values assigned on add, never keyed on and never persisted. Formula has no id at all and is effectively identified by (target, target_category). So an edit or rename can only be expressed as remove-plus-add, which loses data, and an edit whose target was concurrently replaced can't be told apart from an edit of the replacement.\n\nScope: write an ADR (context/adr/004 or the next free number) that decides:\n(1) What carries an identity: category, item, formula, and possibly view.\n(2) Transient or persistent. The user accepts transient, meaning assigned at load or creation, valid for the life of a hosting session, and never written to .improv. Say what breaks across a reload, a document swap (document_generation), and a rebase.\n(3) How ids cross the wire: ModelCommands name ids rather than names for edit, rename and replace, while add keeps names. Also how a client learns the ids (projection: CategoryTreeCache, FormulaCache).\n(4) How this interacts with ADR 002's RecordHash / CellId surrogates, and the basis check (ADR 001), e.g. an edit naming a formula id that no longer exists is RecordGone-like.\n(5) The migration path. The existing usize ids may be reusable.\n\nConsumers: improvise-km71 (formula edit), improvise-fz77 (category/item rename), and improvise-fdk1 (records coordinate rename).","notes":"USER DIRECTION 2026-09-23 (file format): 'It's probably worth a file format change (in an opt-in way, that gets rectified on save if the input file was missing them).' So ids ARE persisted in .improv. A file without ids stays legal and loads, with ids assigned at load. Saving writes them, so the file is rectified on save. The ADR must cover: the grammar change to improv.pest (it is the single source of truth; the grammar-walking generator must still produce valid files both with and without ids); the round-trip properties (a file without ids parses, gets them on save, and is then stable); what happens when a hand-edited file has duplicate or colliding ids; that legacy JSON stays read-only; and how ids survive the import wizard and CSV.\nUSER SYNTAX SUGGESTION 2026-09-23: 'Maybe something like View: Default(default) and the standard View: |Default (default)|(default) for quoting.' That is, the id follows the name in parentheses, immediately after a bare or pipe-quoted name. The example is a View, which implies views carry ids too.","status":"in_progress","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:06:55Z","created_by":"orchestrator","updated_at":"2026-09-23T06:07:40Z","started_at":"2026-09-23T06:07:38Z","lease_expires_at":"2026-09-23T06:12:38Z","heartbeat_at":"2026-09-23T06:07:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sboz","title":"ADR: stable identities for categories, items and formulas (transient is acceptable)","description":"USER DIRECTION 2026-09-23: 'there would be a lot of benefit to categories/formulas having stable identities, even if they were only transient: it would help pin editing operations and distinguish them from delete/remove.'\n\nToday the name is the identity. Category.id and Item.id are usize values assigned on add, never keyed on and never persisted. Formula has no id at all and is effectively identified by (target, target_category). So an edit or rename can only be expressed as remove-plus-add, which loses data, and an edit whose target was concurrently replaced can't be told apart from an edit of the replacement.\n\nScope: write an ADR (context/adr/004 or the next free number) that decides:\n(1) What carries an identity: category, item, formula, and possibly view.\n(2) Transient or persistent. The user accepts transient, meaning assigned at load or creation, valid for the life of a hosting session, and never written to .improv. Say what breaks across a reload, a document swap (document_generation), and a rebase.\n(3) How ids cross the wire: ModelCommands name ids rather than names for edit, rename and replace, while add keeps names. Also how a client learns the ids (projection: CategoryTreeCache, FormulaCache).\n(4) How this interacts with ADR 002's RecordHash / CellId surrogates, and the basis check (ADR 001), e.g. an edit naming a formula id that no longer exists is RecordGone-like.\n(5) The migration path. The existing usize ids may be reusable.\n\nConsumers: improvise-km71 (formula edit), improvise-fz77 (category/item rename), and improvise-fdk1 (records coordinate rename).","notes":"USER DIRECTION 2026-09-23 (file format): 'It's probably worth a file format change (in an opt-in way, that gets rectified on save if the input file was missing them).' So ids ARE persisted in .improv. A file without ids stays legal and loads, with ids assigned at load. Saving writes them, so the file is rectified on save. The ADR must cover: the grammar change to improv.pest (it is the single source of truth; the grammar-walking generator must still produce valid files both with and without ids); the round-trip properties (a file without ids parses, gets them on save, and is then stable); what happens when a hand-edited file has duplicate or colliding ids; that legacy JSON stays read-only; and how ids survive the import wizard and CSV.\nUSER SYNTAX SUGGESTION 2026-09-23: 'Maybe something like View: Default(default) and the standard View: |Default (default)|(default) for quoting.' That is, the id follows the name in parentheses, immediately after a bare or pipe-quoted name. The example is a View, which implies views carry ids too.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:06:55Z","created_by":"orchestrator","updated_at":"2026-09-25T00:29:09Z","started_at":"2026-09-23T06:07:38Z","closed_at":"2026-09-25T00:29:09Z","close_reason":"ADR 007 accepted by the user (comment 5824468104) and merged in PR #210 (2212d9b4)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-km71","title":"A formula cannot be edited from the formula panel","description":"Reported by the user, 2026-09-23. The formula panel can add a formula (FormulaEdit then commit-buffer formula) and delete one (delete-formula-at-cursor), but it cannot edit an existing one. The only way to change a formula is to delete it and retype it. Expected: a key on the formula under the cursor (Enter or e, matching whatever the panels already use for 'act on this item') opens FormulaEdit with the buffer seeded from that formula's raw text. Committing replaces that formula in place, keeping its position and target, instead of adding a new one. It must work in both front ends: the browser formula panel as well as the TUI (see repo-map gotcha 47 for where the browser resolves keys). Open questions for the implementer: if the edit changes the target, is it the same formula or a new one? And what does Esc restore?","notes":"Orchestrator context, 2026-09-23: identity is the name. Category and Item have a usize 'id' (category.rs), but nothing keys on it. It is returned by add_category/add_item and never persisted in .improv. Model.categories is an IndexMap keyed by name, CellKey and DataStore symbols are names, and views, hidden/collapsed and formulas all refer by name. A Formula has no id at all: {raw, target, target_category, expr, filter} in a Vec, in effect identified by (target, target_category). So a rename must rewrite every name reference, or the design must first introduce a stable identity. Weigh the two before implementing.","status":"closed","priority":2,"issue_type":"feature","assignee":"agent-km71","owner":"el-github@elangley.org","created_at":"2026-09-23T06:03:24Z","created_by":"orchestrator","updated_at":"2026-09-23T16:00:12Z","started_at":"2026-09-23T06:05:11Z","closed_at":"2026-09-23T16:00:12Z","close_reason":"Fixed in PR #214 (formula editing, user-requested), merged after adversarial review (77f059c, then the kb1d delta at f3fb83e). Later changes were conflict-only gh/main merges, the doc-comment fix, and serde re-added for FormulaId only (orchestrator ruling, after tt22).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5xbq","title":"No test compares a page-scroll landing between host and browser: halving page_rows, or paging by window_cols, passes the whole workspace (lockstep.rs covers only j and G)","description":"Found reviewing PR #198 at f75b58af (delta review for improvise-5kdr).\n\n#198 moves the browser's page rule into `resolve.rs::page_rows(window_rows)`, and `every_grid_move_lands_where_its_binding_says` reads that function. So a wrong page rule moves the code and the test together. The evidence entry says whether the rule matches the TUI \"is `tests/lockstep.rs`'s question\". That is not true. `lockstep.rs` compares only `j` (`a_locally_resolved_cursor_move_lands_where_the_host_would_put_it`) and `G` against the host. `a_locally_resolved_page_turn_…` is about page-axis cycling, not `page-scroll`. No test presses PgDn/PgUp or Ctrl-d/Ctrl-u on both ends.\n\nThe TUI's rule is written a second time at `improvise-command/src/command/cmd/navigation.rs:121` (`visible_rows * 3 / 4, max 1`). The two copies agree today, but nothing holds them together.\n\nInjected at f75b58af, `cargo test --workspace --no-fail-fast`, whole workspace **green** for each:\n- `page_rows` becomes `window_rows / 2`\n- `grid_move`'s Page arm uses `page_rows(window_cols)`. This is also invisible because the test's window is square (`WINDOW = (8, 8)`), so rows and columns cannot be told apart.\n\nWanted: a lockstep test that presses PgDn, PgUp, Ctrl-d and Ctrl-u from a non-edge start in a non-square window (rows != cols) and compares the client's cursor and offsets with `reduce_key` on a real `App`. Better still, share one page rule between the two crates (design-principles §5: a derivation done twice is the defect).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:58:15Z","created_by":"agent-review-191-200-198","updated_at":"2026-09-23T14:59:03Z","started_at":"2026-09-23T06:37:32Z","closed_at":"2026-09-23T14:59:03Z","close_reason":"Fixed in PR #215, merged after adversarial review at a8737f3. Follow-up: improvise-mv1d.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-q9eg","title":"PR #197: TUI p in records mode refuses a record that lacks a coordinate (e.g. after +cat) as an 'aggregate'; the browser pastes","description":"Found in the adversarial review of PR #197 at head 097533e5. It is a regression that #197 introduces, and TUI and browser now disagree.\n\nRepro: records mode, where every regular category sits on None. Add a category (the `+cat` column, or `wb.add_category(\"Product\")`), so the existing records do not carry it yet. Yank a value and press `p` on any measure cell.\n- gh/main (98d10ca5): the value is written (West/Cost 400 -\u003e 42).\n- PR #197: nothing is written, and the status reads \"Cannot write to an aggregate — it combines several records\". `x` on the same cell still clears it. The browser still composes the SetCell, because `cell_id` says `Record`.\n\nCause: `value_cell_key` resolves the records position to the real record key and hands it to `PasteCell`. `paste_into` then re-runs its own `aggregates(ctx, key)` check. That check asks whether the key leaves out a regular None-axis category, and in records mode every regular category is on None, so any record that lacks one coordinate reads as an aggregate. Before #197 the cursor path passed the synthetic key, which took `paste_into_record` and never reached that check. context/code/command.md claims that \"the checks in `paste_into` are reached only by a key a script spells out\". That claim is false: the cursor path reaches them too, so there is still a second derivation beside `cell_id`, and it disagrees with it.\n\nFix direction: keep the key-shape checks off a key the door has already resolved, for example by skipping `aggregates` in records mode or giving the door path its own constructor. Add a lockstep case for `p` on a sparse record.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:55:50Z","created_by":"agent-review-197","updated_at":"2026-09-23T15:19:01Z","closed_at":"2026-09-23T15:19:01Z","close_reason":"Fixed in PR #197, merged as 705c92d after adversarial re-review at 087aa85. Later changes were evidence-only merges of gh/main. Follow-ups: improvise-ywj2, improvise-ciw8 (#217).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-phg1","title":"RecordRewrite: the lone-cell, sparse-row and read-before-clear branches have no test that can fail","description":"Found by adversarial review of PR #204 (improvise-uo2p) at head 92eefeec. The code at that head is correct on all three points below; what is missing is a test that can fail. Three rivals injected into effect::RecordRewrite pass the whole of improvise-command (593 tests), and the first passes the whole workspace (1832):\n\n1. Lone measure-less cell. In `RecordRewrite::of`, change `(false, true) =\u003e vec![(None, row.coords.clone())]` to `Vec::new()`. Renaming a row seeded by `o` / add-record-row then leaves `CellKey([])` behind, so the row loses its coordinates. No test in the workspace fails. A handle_key fuzz trace does catch it. This gap predates #204: the same branch lived in `rename_record_coordinate` and the test set is unchanged.\n2. Sparse drill row. In the second loop of `of` (measures the row has no cell for), change `renamed(\u0026row.cell_key(measure))` to `row.cell_key(measure)`. That reintroduces uo2p's ghost record at the old coordinates for any drill row missing a measure. Every #204 fixture row carries every measure, so the fixture lacks the structure the hazard needs.\n3. Reads before any clear. In `ApplyAndClearDrill`, apply each row's clears as soon as that row's rewrite is built, before the next row reads the model. The suite stays green. A probe fails it and passes on #204: drill rows Food{Revenue=1} and Clothing{Revenue=42, Profit=5}; stage row0 Type-\u003eClothing, row0 Profit=\"\" (a blank edit on an absent measure clears the *destination* key), row1 Type-\u003eFood; expect Food/Jan/Profit = 5.\n\nAcceptance: one test per rival that fails under it and passes at the fix, with evidence entries in context/test-evidence/command.md.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:49:50Z","created_by":"agent-review-204","updated_at":"2026-09-23T06:39:04Z","started_at":"2026-09-23T05:54:59Z","closed_at":"2026-09-23T06:39:04Z","close_reason":"Fixed in PR #209, merged as ea119bd after adversarial review at ccb517c. q2mq was the reviewer-requested fourth test; the merge touched evidence only.","labels":["adversarial-review","pr-204"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vndi","title":"An inline edit opened before a document swap is accepted into the new document: EditTarget does not capture document_generation","description":"Found re-reviewing PR #188 (improvise-2wu3 + improvise-0j0f) at head a5fbd84f. EditTarget is { cell, version }, and BasisCache::claim_as_read states that record version beside the CURRENT document_generation and layout_version. context/code/clients.md argues the layout tier must stay current (2wu3's renumbering moves it and the identity already makes the write safe) and that argument is right. It does not extend to document_generation, and the doc says 'the document tiers' plural. Rejection::DocumentReplaced's own doc says nothing composed against the outgoing document names anything in the incoming one, whatever tier it claimed.\n\nCONFIRMED with a scratch test against a real Host (not committed). Host over MemoryFs holding other.improv = the same shape with North/Revenue = 5555. A opens the editor on North/Revenue (1200), target version 0. B sends LoadModel(other.improv); A folds the rebase Snapshot (generation 1) without repainting, which is what web/app.js does: repaint() returns early while editing, and nothing closes the editor on a Snapshot. A commits '42'. The frame claims generation 1, record (North, 0). The host ACCEPTS it: the loaded document's 5555, which A never saw, becomes 42, and no one is told. The same RecordHash resolves in the new document because RecordHash is coordinate-derived, and the fresh DataStore's counter matches.\n\nNot introduced by #188: claiming at commit (the rival) and the pre-2wu3 position path accept too. But #188 is where the editor first captures a basis at open, so it is the natural place to capture the generation. Fix shape: EditTarget gains document_generation (or claim_as_read takes it); keep layout_version current. The test should assert Rejected(DocumentReplaced) and that the loaded value survives. The scenario is a two-client LoadModel while an editor is open, and a rename-free fixture is required, see gotcha 39's fixture warning.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:41:56Z","created_by":"agent-review-188b","updated_at":"2026-09-23T17:52:54Z","started_at":"2026-09-23T14:44:07Z","closed_at":"2026-09-23T17:52:54Z","close_reason":"Fixed in PR #219, merged as 202e7ea after adversarial review at 5690903. Later: the agreed union with #205's EditTarget and a gh/main merge; the reviewer had verified the union.","labels":["adversarial-review","pr-188","protocol","wasm-client"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-wm20","title":"The totals strip sums a formula column's results: a ratio column totals 1.50 where the formula over the totalled inputs is the answer","description":"Found while fixing improvise-eccl, filed rather than fixed as the brief directed. viewport.rs::grid_body builds the totals strip by summing each column's cell values over every row. For a raw measure under Sum that is the column's total. For a formula target it is the sum of the formula's per-row results: Margin = (Revenue - Cost) / Revenue at 0.50 on each of three rows totals 1.50, a 150% margin. improvise-eccl ruled that a formula is evaluated over aggregated inputs, never folded itself, which is what Axis::None has always done. The collapsed-group fold now obeys that ruling and the strip does not, so the fold and the total disagree on every ratio column. Proposed answer: a formula column's total is the formula evaluated at GridLayout::column_key(col) with the row categories aggregated away, the value the cell has when the row categories sit on Axis::None. The formula cache does not hold that key when no row category is on None, so evaluate it rather than read the cache. Test with a ratio (a sum agrees under both rules) and with two rows whose ratios differ, so sum, mean and pooled ratio all differ. Mind improvise-pakc, which is about the strip's CellId, not its value.","notes":"USER RULINGS 2026-09-23: the column total equals the column's cell as if every drawn row were one fold (pooled; Margin = total Profit / total Revenue; Avg = mean over records). (1) A text column uses the unanimity rule: the text if every row agrees, otherwise 'mixed'. (2) An error in any row propagates, so the total is an error. (3) When _Measure is on the row axis, the totals strip is empty. Build on top of #203 once it merges.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:45Z","created_by":"agent-collapse-fu","updated_at":"2026-09-23T15:31:50Z","started_at":"2026-09-23T15:31:50Z","lease_expires_at":"2026-09-23T15:36:50Z","heartbeat_at":"2026-09-23T15:31:50Z","comments":[{"id":"41d898a8-8705-580e-815d-3516f207daca","issue_id":"improvise-wm20","author":"Edward Langley","text":"Delta re-review of PR #203 at b3379d1a (agent-review-197-203b). The strip also moves on `z` for a **stored** measure whose `measure_agg` is not Sum or Count. With Month Q1 = {Jan, Feb}, Region on None and Revenue under Max (Jan N/S 5/1, Feb N/S 2/7), the column sum reads 12 expanded (5 + 7) and 7 collapsed (the max over Q1). The cause is the same as this ticket's: `viewport.rs::grid_body` sums rows whatever the aggregator is. So the fix should cover the stored non-Sum case too. PR #203's core.md §14 currently says \"the total of a **stored** measure does not move on `z`\". That holds only under Sum/Count.\n","created_at":"2026-09-23T14:54:28Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-wm20","title":"The totals strip sums a formula column's results: a ratio column totals 1.50 where the formula over the totalled inputs is the answer","description":"Found while fixing improvise-eccl, filed rather than fixed as the brief directed. viewport.rs::grid_body builds the totals strip by summing each column's cell values over every row. For a raw measure under Sum that is the column's total. For a formula target it is the sum of the formula's per-row results: Margin = (Revenue - Cost) / Revenue at 0.50 on each of three rows totals 1.50, a 150% margin. improvise-eccl ruled that a formula is evaluated over aggregated inputs, never folded itself, which is what Axis::None has always done. The collapsed-group fold now obeys that ruling and the strip does not, so the fold and the total disagree on every ratio column. Proposed answer: a formula column's total is the formula evaluated at GridLayout::column_key(col) with the row categories aggregated away, the value the cell has when the row categories sit on Axis::None. The formula cache does not hold that key when no row category is on None, so evaluate it rather than read the cache. Test with a ratio (a sum agrees under both rules) and with two rows whose ratios differ, so sum, mean and pooled ratio all differ. Mind improvise-pakc, which is about the strip's CellId, not its value.","notes":"USER RULINGS 2026-09-23: the column total equals the column's cell as if every drawn row were one fold (pooled; Margin = total Profit / total Revenue; Avg = mean over records). (1) A text column uses the unanimity rule: the text if every row agrees, otherwise 'mixed'. (2) An error in any row propagates, so the total is an error. (3) When _Measure is on the row axis, the totals strip is empty. Build on top of #203 once it merges.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:45Z","created_by":"agent-collapse-fu","updated_at":"2026-09-24T21:45:06Z","started_at":"2026-09-23T15:31:50Z","closed_at":"2026-09-24T21:45:06Z","close_reason":"Landed in main via #239 (reviewed; merged 2026-09-24).","comments":[{"id":"41d898a8-8705-580e-815d-3516f207daca","issue_id":"improvise-wm20","author":"Edward Langley","text":"Delta re-review of PR #203 at b3379d1a (agent-review-197-203b). The strip also moves on `z` for a **stored** measure whose `measure_agg` is not Sum or Count. With Month Q1 = {Jan, Feb}, Region on None and Revenue under Max (Jan N/S 5/1, Feb N/S 2/7), the column sum reads 12 expanded (5 + 7) and 7 collapsed (the max over Q1). The cause is the same as this ticket's: `viewport.rs::grid_body` sums rows whatever the aggregator is. So the fix should cover the stored non-Sum case too. PR #203's core.md §14 currently says \"the total of a **stored** measure does not move on `z`\". That holds only under Sum/Count.\n","created_at":"2026-09-23T14:54:28Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-5kdr","title":"every_grid_move_lands_where_its_binding_says sees only the sign of a move: 3x3-from-centre clamps every step, so Ctrl-d moving 1 row and PgDn ignoring the page pass the workspace","description":"Found in adversarial review of PR #198 at head 8772db2e (the improvise-w7qd fix).\n\nevery_grid_move_lands_where_its_binding_says presses every Normal binding from (1,1) of a 3x3 grid (window 3x3) and states each landing as clamp(1+d, 0, 2). From the centre of 3x3 every step of magnitude \u003e= 1 clamps to the edge, so the test sees a SIGN and never a MAGNITUDE. Worse, it states page-scroll's landing as 1 + arg (one row), which is not what page-scroll does (window_rows*3/4 rows); the two coincide only because both clamp.\n\nMEASURED, rivals in crates/improvise-protocol/src/resolve.rs, `cargo test --workspace --no-fail-fast`:\n- Rival B: \"page-scroll\" delta = dir (ignores the page size) AND \"scroll-rows\" Relative(num(args,0)?.signum(), 0) (Ctrl-d/u move 1 row, not 5). WHOLE WORKSPACE GREEN (38 binaries ok). No harness spec presses PgUp/PgDn/Ctrl-d/Ctrl-u either.\n- Rival A: move-selection moves 2*d. The w7qd test PASSES; only lockstep's a_locally_resolved_cursor_move_lands_where_the_host_would_put_it catches it.\n\nA discriminating fixture, prototyped and run: extent 21x21, window (8,8), start (10,10); move-selection/scroll-rows land at 10+d, page-scroll at 10+6*d, jumps at 0/20. Passes on the PR's code; fails on rival B at `PageUp page-scroll [\"-1\"]`, on the scroll-rows half alone at `Ctrl-u scroll-rows [\"-5\"]`, and on rival A at `Up move-selection [\"-1\",\"0\"]`. Cost: the page rule (3/4 of the window) is stated a second time in the test; alternatively state page-scroll's landing from a shared helper.\n\nAlso: the assertion messages still say \"from (1,1)\" and would need the start named.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:35Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:39:24Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","labels":["browser","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w1bu","title":"#196's whole-grid enumeration asserts no None, so a coordinate mis-projected as an editable Record passes","description":"Found in adversarial pre-merge review of PR #196 (head 6b3dd8c).\n\n#196 deletes `a_coordinate_column_is_projected_with_no_identity`, which pinned row 0's Region coordinate as `None`. It relaxes `a_creating_column_is_named_rather_than_left_as_no_cell` from \"exactly one None on row 0\" to \"all Some on row 0\". It adds `a_coordinate_cell_is_named_rather_than_left_as_no_cell`, which pins the exact kind at ONE cell, (1, region). The whole-grid enumeration in that test then asserts only that no cell is `None`.\n\nThat enumeration answers \"is every cell something\", not \"is every cell the right thing\". The dangerous mis-projection is a coordinate projected as `CellId::Record`: `render.rs::editable` then offers it, and a typed value becomes a `SetCell` with `measure: Some(\"Region\")`. The enumeration cannot see that, and after the PR no other test sees it off row 1.\n\nCONFIRMED by injection in `cell_id` (ui/project.rs), at 6b3dd8c:\n RecordCol::Category(c) if row == 0 =\u003e Some(c.clone()), // falls through to CellId::Record\nEvery test in `cargo test --workspace --no-fail-fast` passed, including all 26 in ui::project::cell_identity. At gh/main the same injection fails `a_coordinate_column_is_projected_with_no_identity` and the old exactly-one-None count. So the replacement is strictly less discriminating than what it replaced, in the direction that corrupts data. The same injection keyed to the last row IS caught, but only by the single-cell spot check at (1, region), because records_app has two rows.\n\nRepair shape: in the enumeration, assert each cell's kind against `layout.record_col(c)`: Category -\u003e Coordinate, Measure -\u003e Record or Derived, NewMeasure/NewCategory -\u003e Create. Do not assert `is_some`. Check it with the row-0 injection above.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:20:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:35:10Z","started_at":"2026-09-23T05:34:15Z","closed_at":"2026-09-23T06:35:10Z","close_reason":"Fixed in PR #206, merged as 2a52888 after adversarial review at b22f6e5. hoxy was the reviewer-requested test fix; later merges touched evidence only. Follow-up: improvise-v8ab (#207).","dependencies":[{"issue_id":"improvise-w1bu","depends_on_id":"improvise-exdg","type":"related","created_at":"2026-09-23T05:20:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lxg0","title":"A fresh or rebased client's Snapshot carries a phantom back-stack frame naming \"Default\"","description":"Found in adversarial pre-merge review of PR #193 (head f8f4dd5). Pre-existing on gh/main 1a6b877; not introduced by #193, but #193's new text in context/code/command.md rests on its absence.\n\n`project.rs::fresh_view` builds a new client's slice as `ViewState::default()` and then folds `Command::SwitchView(workbook.active_view)`. `ViewState::default().active_view` is the literal \"Default\". `reduce_view::switched` pushes a frame whenever the name it leaves differs from the one it enters. So any document whose active view is not \"Default\" hands every newly connected client a back-stack frame `{view_name: \"Default\", mode: Normal}` that the client never visited. `Sessions::rebase` goes through `reseed` -\u003e `fresh_view`, so a document swap does the same. The doc comment says the slice is \"empty\", so the intent is clear.\n\nCONFIRMED with a probe test in ui/project.rs tests (not committed): `app_with_two_views()`, connect, then `install_workbook(other_document())` with \"Default\" deleted, then `rebase`. The snapshot's `view_back_stack` is `[ViewFrame { view_name: \"Default\", mode: Normal }]`. That frame names a view the incoming document does not have.\n\nConsequences:\n- If the document has a view named \"Default\" (every `Workbook::new` does) but opens elsewhere, `\u003c` in the browser moves the client to \"Default\", which it never visited. That is improvise-lrrx's symptom on the client side.\n- If it has no \"Default\", the client and its server mirror both step onto a dead name (`reduce_view::stepped` does not check). `resolve_view` falls back to the default, so `\u003c` visibly does nothing and pushes a forward frame.\n- The TUI host does not have this. `App::with_storage` sets `active_view` directly, so host and client histories differ from the first frame.\n- #193's new command.md paragraph says the client's stacks \"cannot hold a dead name: ForgetView purges them, and a load arrives as a fresh Snapshot\". This is the counterexample. That claim is the stated reason `reduce_view::stepped` may stay unchecked while `effect::stepped_to` refuses a dead frame.\n\nRepair shape: build fresh_view with `ViewState { active_view: workbook.active_view.clone(), ..ViewState::default() }`, as App::with_storage does. Do not fold a SwitchView. Test: connect and rebase against a document whose active view is NOT \"Default\" and that HAS a \"Default\" view. Assert the snapshot's back stack is empty. A fixture opening on \"Default\" is right by accident.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:20:16Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:35:10Z","started_at":"2026-09-23T05:34:12Z","closed_at":"2026-09-23T06:35:10Z","close_reason":"Fixed in PR #206, merged as 2a52888 after adversarial review at b22f6e5. hoxy was the reviewer-requested test fix; later merges touched evidence only. Follow-up: improvise-v8ab (#207).","dependencies":[{"issue_id":"improvise-lxg0","depends_on_id":"improvise-lrrx","type":"related","created_at":"2026-09-23T05:20:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e1sm","title":"Determinism law passes about 14% of the time with gotcha 18 re-injected, and cannot see nondeterminism inside Effect::apply","description":"Found in adversarial review of PR #187 (improvise-hzjo) at head fc0b13c, merged locally with gh/main da2e5ab.\n\n1. **The law catches gotcha 18 only about 86% of the time.** I re-injected gotcha 18 by commenting out `positions.sort_unstable()` in `DataStore::matching_positions`. `every_command_gives_equal_effects_in_independently_built_equal_contexts` then **passed 55 times in 400 runs**, about 14%. Over 60 failing runs, exactly one (command, args) pair ever diverged: `drill-into-cell [\"Product/Latte\", \"Quarter/Q2\"]`. That drill is one record with two values, so each other instance agrees with probability 1/2, and (1/2)^3 is 12.5%. `fit-measure` never diverged, although the evidence entry says the injection is \"reached through `drill-into-cell` and `fit-measure`'s record order\". The \"four instances make it negligible\" argument holds for the 21-entry buffer rival. It does not hold for the store, where the fixture's orderable set is two cells.\n With `vec![\"Product/Latte\"]` added to `args_the_document_names` (4 records × 2 measures), the injected law failed **400 of 400**. Without the injection it passed 400 of 400, and so did the companion test. No spurious failures.\n Fix: add a drill argument that matches several records. Correct the evidence entry, which names fit-measure. State the residual rate as measured rather than argued.\n\n2. **Scope the law says it has but does not have.** It compares the `Debug` of the effects `execute` returns. Many effects are unit structs that do their work in `apply`: `ApplyAndClearDrill`, `SortData`, `CleanEmptyRecords`, `Save`, `RebuildLayout`, `AcceptWizardModel`. Hash-order nondeterminism inside an `apply` is therefore invisible to the law, and improvise-uo2p is a live P1 instance of it. `context/code/command.md` says the law guards \"the one kind of nondeterminism this repo has actually shipped\". Its \"What it cannot see\" paragraph should add that the law covers `execute` and not `Effect::apply`.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:09:48Z","created_by":"agent-review-182","updated_at":"2026-09-23T05:52:25Z","started_at":"2026-09-23T05:16:55Z","closed_at":"2026-09-23T05:52:25Z","close_reason":"Fixed in PR #187, merged after adversarial review (content at fc0b13c, fixture delta at 0825f99; later changes were an evidence-only merge and doc corrections). Follow-up: improvise-b9f2.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0j0f","title":"The inline editor claims its basis at commit, not open: a value another client wrote while the editor was open is silently overwritten","description":"Found reviewing PR #188 (improvise-2wu3), head 384e04ac. That PR captures the edited cell's IDENTITY when the editor opens (edit_target), which fixes the misdirected write. It does not capture the BASIS: typed_record_frame calls self.state().basis.claim([cell.record]) at COMMIT time, and the page's fold goes on while the screen is frozen, so the claim states the version the user was never shown.\n\nCONFIRMED with a scratch test against a real Host at 384e04ac (not committed). Two clients; North/Revenue = 1200. A opens the editor on it (edit_target(0,0)). B writes 999 to the same cell; A folds the relayed frames without repainting, as the page does. A commits '42' via typed_cell(target). The frame claims [(North, 1)], the post-write version, and the host ACCEPTS it: the cell ends at 42, B's 999 is gone, and nobody is told. Same result when B CLEARS the cell: A's commit re-creates it at 42, accepted.\nControl, same fixture: replacing the frame's records with the claim taken at open ([(North, 0)]) gets Rejected(RecordMoved) and 999 survives. So the host's check is sound; the client asks it the wrong question.\n\nThis contradicts the PR's own new text in context/code/clients.md: 'if the record is written by someone else meanwhile, the basis tier refuses it (RecordMoved)'. It does not.\n\nHeld in the same probe: rows renumbered above the editor (ShowItem of a row above) -\u003e the write lands on the opened record; the opened record's item removed (RemoveItem) -\u003e Rejected(RecordGone), nothing resurrected.\n\nPre-existing on gh/main (typed_cell also claimed at commit), but 2wu3's diagnosis ('the stale thing is the screen, and nothing measures that') is only half closed without it. Fix: edit_target returns the record's version (or the whole claim) alongside the RecordCell, and typed_cell claims that. Guard: the scenario above, asserting Rejected(RecordMoved) and the other client's value intact. The keyboard editor (improvise-8xq0) likely wants the same answer.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:00Z","created_by":"agent-review-183","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T05:10:33Z","closed_at":"2026-09-23T06:35:13Z","close_reason":"Fixed in PR #188, merged as de70373 after adversarial re-review at a5fbd84. au6c and 4img were the review-requested fixes; later merges touched evidence only. Follow-up: improvise-vndi.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uv9e","title":"toggle-group-under-cursor ignoring the cursor passes the whole workspace: every test sits at selected (0,0)","description":"Found reviewing PR #183 (improvise-y1av), head a4910eb4, which closed the cursor-at-0 class for the formula/view panel commands. The class is not closed in grid.rs.\n\nToggleGroupAtCursor::execute reads ctx.layout.row_group_for(ctx.selected.0) / col_group_for(ctx.selected.1). Its three tests (toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it, 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) all run with the cursor at (0,0), and the fixture puts the first group at row 0, so the group under the cursor and the group at row 0 are the same group in every case. The second test re-collapses at row 0 too.\n\nCONFIRMED by injection at a4910eb4: row_group_for(0) and col_group_for(0) in place of the cursor reads. cargo test --workspace --no-fail-fast: 1771 passed, 0 failed (same as baseline). Reverted.\n\nThe column variant (toggle-col-group-under-cursor, is_row: false) has no command-level test at all.\n\nFix: seat selected.0 on a row in the SECOND group (e.g. Apr, row 2) and assert Q2 is named and Q1 is not; add the column twin with selected.1 nonzero. Checked the neighbours while here: HideSelectedRowItem with .nth(0) is caught by headless_cli; OpenItemAddAtCursor/ToggleCatExpand reading entry 0 are caught only by wasm-client lockstep (the_category_panel_bindings_agree_with_the_host), with no command-level unit test.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T04:57:26Z","created_by":"agent-review-183","updated_at":"2026-09-23T23:54:35Z","started_at":"2026-09-23T23:54:35Z","lease_expires_at":"2026-09-23T23:59:35Z","heartbeat_at":"2026-09-23T23:54:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uv9e","title":"toggle-group-under-cursor ignoring the cursor passes the whole workspace: every test sits at selected (0,0)","description":"Found reviewing PR #183 (improvise-y1av), head a4910eb4, which closed the cursor-at-0 class for the formula/view panel commands. The class is not closed in grid.rs.\n\nToggleGroupAtCursor::execute reads ctx.layout.row_group_for(ctx.selected.0) / col_group_for(ctx.selected.1). Its three tests (toggling_at_the_cursor_names_the_group_the_cursor_sits_in_and_collapses_it, 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) all run with the cursor at (0,0), and the fixture puts the first group at row 0, so the group under the cursor and the group at row 0 are the same group in every case. The second test re-collapses at row 0 too.\n\nCONFIRMED by injection at a4910eb4: row_group_for(0) and col_group_for(0) in place of the cursor reads. cargo test --workspace --no-fail-fast: 1771 passed, 0 failed (same as baseline). Reverted.\n\nThe column variant (toggle-col-group-under-cursor, is_row: false) has no command-level test at all.\n\nFix: seat selected.0 on a row in the SECOND group (e.g. Apr, row 2) and assert Q2 is named and Q1 is not; add the column twin with selected.1 nonzero. Checked the neighbours while here: HideSelectedRowItem with .nth(0) is caught by headless_cli; OpenItemAddAtCursor/ToggleCatExpand reading entry 0 are caught only by wasm-client lockstep (the_category_panel_bindings_agree_with_the_host), with no command-level unit test.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T04:57:26Z","created_by":"agent-review-183","updated_at":"2026-09-24T21:45:01Z","started_at":"2026-09-23T23:54:35Z","closed_at":"2026-09-24T21:45:01Z","close_reason":"Landed in main via #263 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w7qd","title":"every_grid_move_lands_where_its_binding_says omits scroll-rows/page-scroll and its floor lets the jumps vanish: reversed Ctrl-d/u and PgUp/PgDn pass the whole workspace","description":"Found in re-review of PR #180 at head 544744cf, the follow-up to improvise-6562.\n\n`every_grid_move_lands_where_its_binding_says` derives the expected landing of each `move-selection` binding from its own delta args. The four jumps come from a hand-kept table. Two holes remain.\n\n1. **`scroll-rows` and `page-scroll` are not in it**, although both are advertised: `Ctrl-d`/`u` as `scroll-rows ±5`, and `PgDn`/`PgUp` as `page-scroll ±1`.\n - Injected: negate the arg in both resolver arms (`Relative(-num(args,0)?, 0)` and `Page(-num(args,0)?)`), so all four keys scroll the WRONG way.\n - `cargo test --workspace --no-fail-fast`: **0 failures.**\n - The advertised-key guard cannot see it: from (1,1) a reversed key still moves, so it counts as Acts.\n - The args are deltas like `move-selection`'s, so they can be derived the same way. From the centre of a 3x3 grid, ±5 clamps to row 0 or row 2 and ±1 page does the same, so direction is still visible.\n2. **The floor does not protect the jumps.** The walk presses 22 sequences against `pressed \u003e= 14`. The `move-selection` keys alone make 16. Injected: drop the jump table's `expected.push(..)` AND apply the `$` → `ToStart(false)` rival. **The whole workspace is green.**\n\nFix:\n- Derive `scroll-rows` and `page-scroll` from their args as `move-selection` is.\n- Replace the numeric floor with a per-entry assertion: every expected `(command, args)` presses at least one sequence.\n- Then re-run both injections.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:51:46Z","created_by":"agent-review-180","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:03:25Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0v8l","title":"23 of 27 refusal conversions in #185 are unpinned: reverting any one to set_status leaves the suite green","description":"Found by the adversarial review of PR #185 (improvise-c5ip/udt2), head 8d51ad7.\n\nThe classification of each writer as \"refusal\" (App::refuse / effect::refuse) or \"report\" (set_status) is what the headless exit code reads, but most of it is unpinned. A mutation sweep reverted each of the 27 conversions one at a time (effect::refuse -\u003e effect::set_status, app.refuse(x) -\u003e app.view_state.status_msg = x) and ran improvise-command's lib tests plus tests/headless_cli.rs.\n\n4 were caught: fit.rs:86, commit.rs:839 (CommitSave), effect.rs:108 (AddItem), effect.rs:742 (report_file_write).\n\n23 survived, i.e. could silently go back to exiting 0:\n- cmd/commit.rs:510 (rename_record_coordinate empty), :863 (CommitExport empty path)\n- cmd/mode.rs:337, :344 (EditOrDrill nothing-at-cursor, formula-owned cell)\n- cmd/grid.rs:401, :407 (ViewNavigate), :548, :576 (AddRecordRow)\n- cmd/text_buffer.rs:382 (ExecuteCommand parse error)\n- cmd/search.rs:130 (no matches)\n- ui/effect.rs:86 (AddCategory — note this one used to exit 1 via its abort; the abort is still there, but the exit code no longer reads it, so the refuse call is now the only thing keeping add-category at the limit exiting 1, and nothing pins it)\n- ui/effect.rs:129 (AddItemInGroup), :195 (AddFormula), :672 (apply_coordinate_rename), :894, :898 (StartImportWizard), :938 (LoadModel), :996, :1007, :1015, :1022, :1029, :1068 (ImportJsonHeadless)\n\nSuggested shape, not a per-site test each: one enumerating test (or a table) that drives each refusal's failure input through apply_effects and asserts last_batch_refused(), with a paired success input asserting it is not — the pairing is what stops a \"refuse everywhere\" rival from passing. At minimum pin AddCategory at MAX_CATEGORIES headless, since that is a pre-existing exit-1 case that lost its only mechanism.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:40:47Z","created_by":"agent-review-185","updated_at":"2026-09-23T14:56:21Z","started_at":"2026-09-23T05:16:54Z","closed_at":"2026-09-23T14:56:21Z","close_reason":"Fixed in PR #213, merged as 345a358 after adversarial review at 3929196. Follow-ups: improvise-we1a, improvise-rtud.","labels":["adversarial-review","area:cli","pr-185"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8xq0","title":"The keyboard editor's commit writes whatever record is under the cursor at Enter, not the one being edited (2wu3's sibling)","description":"Found by agent-wire2 while fixing improvise-2wu3. CONFIRMED with a scratch probe in improvise-wasm-client/tests/browser_round_trip.rs (not committed). Two clients share one Host, and client A has announced its viewport. A presses i on (0,0) (North/Revenue) and types 42, so the buffer is seeded from North. Client B hides North, and A folds the relayed frames. A then presses Enter. resolve.rs::commit composes SetCell against record_at_cursor(state) AT ENTER TIME, which is South's record (RecordHash 246296...), when North's (161215...) was expected. The basis claim is satisfied because A folded the fresh Projection::Basis, so the host accepts the write, which lands on a row the user was not editing. Unlike 2wu3 the screen is not frozen in this path, but the edit buffer still holds North's value. The 2wu3 fix, capturing the RecordCell when the editor opens, does not reach it, because the keyboard editor's state lives in ViewState (mode plus the edit buffer) and names no cell. A fix probably means recording the edited cell's identity when Editing is entered: a ViewState field or a mode payload, which is a design question, touching improvise-ktlk's settle_editor, gotcha 30 membership and gotcha 25 serde.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:29Z","created_by":"agent-wire2","updated_at":"2026-09-24T00:16:06Z","started_at":"2026-09-24T00:16:06Z","lease_expires_at":"2026-09-24T00:21:06Z","heartbeat_at":"2026-09-24T00:16:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8xq0","title":"The keyboard editor's commit writes whatever record is under the cursor at Enter, not the one being edited (2wu3's sibling)","description":"Found by agent-wire2 while fixing improvise-2wu3. CONFIRMED with a scratch probe in improvise-wasm-client/tests/browser_round_trip.rs (not committed). Two clients share one Host, and client A has announced its viewport. A presses i on (0,0) (North/Revenue) and types 42, so the buffer is seeded from North. Client B hides North, and A folds the relayed frames. A then presses Enter. resolve.rs::commit composes SetCell against record_at_cursor(state) AT ENTER TIME, which is South's record (RecordHash 246296...), when North's (161215...) was expected. The basis claim is satisfied because A folded the fresh Projection::Basis, so the host accepts the write, which lands on a row the user was not editing. Unlike 2wu3 the screen is not frozen in this path, but the edit buffer still holds North's value. The 2wu3 fix, capturing the RecordCell when the editor opens, does not reach it, because the keyboard editor's state lives in ViewState (mode plus the edit buffer) and names no cell. A fix probably means recording the edited cell's identity when Editing is entered: a ViewState field or a mode payload, which is a design question, touching improvise-ktlk's settle_editor, gotcha 30 membership and gotcha 25 serde.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:29Z","created_by":"agent-wire2","updated_at":"2026-09-24T21:44:52Z","started_at":"2026-09-24T00:16:06Z","closed_at":"2026-09-24T21:44:52Z","close_reason":"Landed in main via #274 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9fpl","title":"Eight of bok4's refusals (AddItemInGroup, AddFormula parse, ImportJsonHeadless) fail no test when reverted","description":"Found by the adversarial review of PR #181 (improvise-bok4) at head 34ef280.\n\n#181 converts 13 narrated failures in `crates/improvise-command/src/ui/effect.rs` to `refused(app, …)`. I reverted each call site on its own to a status-only write and ran `cargo test --workspace --no-fail-fast`. These reversions **fail no test**:\n\n- `AddItemInGroup` (the `add_item_in_group` Err arm)\n- `AddFormula` (the parse-error arm)\n- all six `ImportJsonHeadless` arms: cannot read (CSV and JSON), CSV error, JSON parse error, array-path error, import error\n\nThese reversions each fail exactly one test: `on_view`, `AddItem`, `DeleteView` and `LoadModel`.\n\nSo eight of the thirteen claimed conversions are unwitnessed. That matters now because PR #185 (c5ip/udt2) rewrites the same lines to `app.refuse(…)`, which does not abort. A conflict resolution that takes #185's side of these eight lines silently drops #181's fix, and nothing turns red. Wanted: a Host-level frame test per effect, or one table-driven test. Each should pair a failing command with a second command that lands, and assert `Rejected(MutationRefused)` and that the landed half is undone.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:32Z","created_by":"agent-review-181","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:40:05Z","closed_at":"2026-09-23T05:14:41Z","close_reason":"Fixed in PR #181, merged as 810077a. Adversarial review passed at 12171fb; the only later changes were merges of gh/main, touching evidence files only (verified at 2619d17). Follow-ups: improvise-514o, and improvise-v4hg (formula-edit parse arm).","labels":["adversarial-review","pr-181"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-40j4","title":"A client DeleteView of an absent view, and AddFormula into an absent category, are still accepted (bok4 residue)","description":"Found by the adversarial review of PR #181 (improvise-bok4) at head 34ef280. These are the same class bok4 closes: a client frame is reported Accepted after one of its commands did nothing, or did something the client did not ask for. PR #181 missed these two sites.\n\n1. **DeleteView of a view the document does not have.** `Workbook::delete_view` returns `Ok(())` for an absent name when there are two or more views, because `shift_remove` is a no-op. So `effect::DeleteView` refuses only the last-view case. Measured through `Host::handle`: with views Default/Scratch/Other, the frame `[DeleteView(\"Scratch\"), DeleteView(\"Scratch\")]` answers `Accepted`, and a single `DeleteView(\"Ghost\")` is also `Accepted`. `on_view`'s own premise is that \"a view the document no longer has is a failure to report\", and it applies that to nine effects but not to DeleteView.\n\n2. **AddFormula into a category the document does not have.** For a non-`_Measure` target, `effect::AddFormula` runs `let _ = app.model_state.workbook.add_item(\u0026formula.target_category, \u0026formula.target)` and then `add_formula` anyway. Measured: `[RemoveCategory(\"Region\"), AddFormula { raw: \"Total = 1\", target_category: \"Region\" }]` answers `Accepted`, and the document now holds a formula `Total [Region]` for a category that does not exist. It persists: `format_md` writes `- Total = 1 [Region]` and `parse_md` reloads it with no Region category. A single `AddFormula { target_category: \"Ghost\" }` is also Accepted. PR #181 converted AddFormula's parse-error arm but missed this `let _`.\n\nFix shape: DeleteView refuses when the named view is absent. AddFormula refuses when `add_item` errs, before `add_formula` runs. Each fix needs a Host-level frame test like the ones #181 added.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:30Z","created_by":"agent-review-181","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:40:05Z","closed_at":"2026-09-23T05:14:41Z","close_reason":"Fixed in PR #181, merged as 810077a. Adversarial review passed at 12171fb; the only later changes were merges of gh/main, touching evidence files only (verified at 2619d17). Follow-ups: improvise-514o, and improvise-v4hg (formula-edit parse arm).","labels":["adversarial-review","pr-181"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v29m","title":"wk18's guard reads only HelpLine::Literal: a hand-built HelpLine::Keys naming an unbound key passes the whole workspace","description":"Found in adversarial review of PR #180 at head 949810cd.\n\nwk18's claim is that a help-page key is looked up, never typed, and that `no_help_row_spells_a_key_by_hand` enforces it. The test reads only `HelpLine::Literal`. `HelpLine::Keys { keys: Vec\u003cString\u003e, .. }` is a public variant any builder can construct directly, and `help_bindings()` records only rows that go through the `keys` parameter, so a hand-built one is invisible to every guard, jhdf's included.\n\nInjected into `panels()` and run:\n\n Some(HelpLine::Keys { keys: vec![\"Q\".to_string()], description: \"Quit instantly\" }),\n\n`Q` is bound to nothing. `cargo test --workspace --no-fail-fast`: **0 failures.** The TUI and the browser (`wasm-client/render.rs`) would both draw it.\n\nA smaller path, also run green: `ex!(\"q or press Q\", ..)`. `every_ex_command_the_help_pages_show_is_one_the_parser_accepts` passes it, because `parse_line` accepts trailing words.\n\nFix options:\n- (a) Make a `Keys` row unconstructible outside `keys()`, e.g. a private field or a private constructor type. That is the repo's \"unrepresentable, not validated\" answer.\n- (b) Assert that the number of `Keys` rows in `pages()` equals the number of rows `help_bindings()` recorded. All of those rows resolve, per `every_keys_row_the_pages_ask_for_finds_its_keys`.\n\nEither way, re-inject the `Q` row and confirm it fails.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:35:07Z","created_by":"agent-review-180","updated_at":"2026-09-23T05:44:50Z","closed_at":"2026-09-23T05:44:50Z","close_reason":"Fixed in PR #180, merged after adversarial review (content reviewed at 544744c; the tip-removal delta and the evidence-only merges were reviewed at f248827). Follow-ups: improvise-1c5m, improvise-17ak.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -177,8 +255,8 @@ {"_type":"issue","id":"improvise-uj4k","title":"wave agent-rules hand-back drops branch, head SHA, PR number and the base-commit check (lost in PR #175's move)","description":"Found reviewing PR #175 at b6d2c47 (adversarial pre-merge review).\n\nPR #175 makes `.agents/skills/wave/agent-rules.md` the one home of the agent-side rules and cuts AGENTS.md's \"Every wave agent's brief must include\" list to a pointer. Two items of the old lists survive in neither new file:\n\n1. **The hand-back's identifiers.** Old AGENTS.md item 7: \"branch and commits, what changed per ticket\". Old wave SKILL.md item 7: \"branch and head SHA, PR number, what changed per ticket\". New agent-rules \"Hand-back, per ticket\" lists claim result, baseline, reproduced, rival, unverifiable, proposed text, not-predicted. No branch, no head SHA, no PR number, no per-ticket change summary. New SKILL.md item 6 re-asks only for \"what the ticket did not predict\". The orchestrator needs the head SHA to dispatch review (SKILL \"Shepherding\": \"give it the head SHA\") and the PR number to watch it (`scripts/pr-watch \u003cpr\u003e --expect \u003csha\u003e`).\n\n2. **The base-commit check.** Old AGENTS.md item 1: \"Verify your base commit first ... `git log --oneline -1` against a named commit\". New SKILL.md item 1 still has the brief name an expected base commit, and agent-rules' intro says the brief supplies it, but no rule tells the agent to compare against it. SKILL item 1's own argument (\"only noticed because it was told to check\") now describes a check nobody is told to make.\n\nNeither was in /tmp/salvage/standing-rules.md either. The loss is in merging the AGENTS/SKILL lists into a rules file that never had them.\n\n## Acceptance Criteria\n- agent-rules.md \"Hand-back, per ticket\" asks for branch, head SHA, PR number and what changed per ticket.\n- agent-rules.md tells the agent what to do with the brief's expected base commit (e.g. check `gh/main` is at or after it after fetching), or SKILL.md item 1 is dropped with a reason.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:28Z","created_by":"agent-review-175","updated_at":"2026-09-23T04:22:20Z","closed_at":"2026-09-23T04:22:20Z","close_reason":"PR #175 MERGED (confirmed at 9f3f530), adversarially reviewed then re-reviewed after fixes (merge). The agent standing rules now live in the repo at .agents/skills/wave/agent-rules.md, replacing the non-durable /tmp/salvage/standing-rules.md. .claude/skills/wave is a symlink to .agents/skills/wave, so the two drifted copies are one. SKILL.md is the brief checklist's only home, and AGENTS.md points to it. The review confirmed every rule from the /tmp file landed, and the fix restored the two items the move had dropped: hand-back identifiers (branch, head SHA, PR number) and the base-commit check.","labels":["adversarial-review","docs","pr-175"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c5ip","title":"A scripted commit-cell-edit whose advance aborts exits 1 silently after saving the write","description":"Found by the adversarial review of PR #171 (improvise-2bac) at head d9b10905. Pre-existing, not introduced by #171; #171 NARROWS it.\n\n`improvise cmd 'move-selection 2 0 . commit-cell-edit editing 9 Region/South'` on a 3-row document (cursor ends on the last row) writes South=9, SAVES the document, prints nothing to stderr, and exits 1. Same for `add-category Foo . commit-cell-edit editing 5 Foo/x` on an empty document.\n\nCause: CommitAndAdvance's advance is EnterAdvance, which returns AbortChain when the cursor is already bottom-right (navigation.rs, \"the advance premise no longer holds\"). The CLI maps `last_batch_aborted()` to exit 1 (context/code/cli.md section 3). But the abort here is not a refusal: the write already landed, and the document is saved because `changed` is true.\n\nMeasured with real binaries:\n- pre-#151 (f7573bf): EVERY scripted commit-cell-edit exited 1 with the write saved, because the pure arm built CursorState::default() (row_count 0), so the advance always aborted.\n- #171 head: exits 0 when the cursor is not bottom-right, 1 when it is. The write lands and is saved in both cases.\n\nWhy it matters: a caller that trusts the exit code believes the document was not written, retries or rolls back, and has nothing on stderr to explain it. It is the mirror image of improvise-udt2 (a refusal that exits 0): here a success exits 1.\n\nLikely fix direction: an advance that has nowhere to go is not an abort of the user's intent in a headless run; either EnterAdvance clamps instead of aborting when it is the tail of a commit, or the CLI distinguishes \"a chain stopped after its mutation landed\" from a refusal. Needs a decision about what AbortChain means in the tail position, since the TUI relies on it to skip trailing effects.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:06Z","created_by":"agent-review-171","updated_at":"2026-09-23T04:42:37Z","started_at":"2026-09-23T04:13:02Z","closed_at":"2026-09-23T04:42:37Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","labels":["adversarial-review","area:cli","pr-171"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yf7p","title":"A registry contextualize may overwrite a parsed field on any command but commit-cell-edit, and nothing fails","description":"Found by the adversarial review of PR #171 (improvise-2bac) at head d9b10905.\n\nPR #171 makes the registry's interactive arm `parse` then `contextualize(C, \u0026CmdContext) -\u003e C`, and states (context/code/cli.md section 8) that one hole stays representable: a `contextualize` can OVERWRITE a field `parse` set. It says the hole \"has its own test\". That test, `commit_cell_edit_honours_an_explicit_value_and_cell_over_the_cursor`, covers commit-cell-edit only.\n\nInjected, run, full workspace suite: four rival contextualizers on OTHER commands, each overwriting a parsed field:\n- yank: `|c, ctx| YankCell { key: cursor_key(ctx).unwrap_or(c.key) }`\n- paste: same shape\n- drill-into-cell: same shape\n- toggle-panel-visibility: `panel: Panel::Formula` (ignores the parsed panel)\n- move-panel-cursor: `delta: cmd.delta.signum()` (clamps an explicit delta)\nResult: 1756 passed, 0 failed. Every one of them is the #151/2bac demotion class (explicit args overridden by context) and nothing in the tree sees it. `every_argument_list_the_gate_accepts_the_run_accepts_too` cannot see it by design: it checks acceptance, not content.\n\nSo the class is closed structurally only for \"the interactive arm refuses what parse accepts\" (contextualize is infallible) and for \"a fallback shadows parse\" (rival b). Overwrite is closed by review discipline plus one command's test.\n\nOptions: (1) make it unrepresentable - split each registered command into a parsed part and a context part, so contextualize returns only the context part and cannot name a parsed field; (2) a registry-wide test: for every name x candidate args that parse accepts, build interactive(args) under two contexts that differ in cursor cell, panel flags, panel cursors and edit buffer, and require the Debug of every arg-derived field to equal parse(args)'s. (1) is the repo's usual answer (doctrine: an invariant is made unrepresentable, not validated).\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-registry","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:01Z","created_by":"agent-review-171","updated_at":"2026-09-23T05:15:07Z","started_at":"2026-09-23T04:12:56Z","closed_at":"2026-09-23T05:15:07Z","close_reason":"Fixed in PR #182, merged after adversarial review at 0a6e940. Follow-up: improvise-3m7b.","labels":["adversarial-review","pr-171"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l4mp","title":"PasteCell writes SetCell onto an aggregate cell: a None-axis aggregate or a collapsed group's fold","description":"Found planning improvise-6qet. PasteCell (command/cmd/cell.rs ~l.170) emits SetCell(self.key, value) with no aggregate check. That was already true for a cell aggregated over a regular category on Axis::None (the write lands on a partial key); since 6qet it also reaches a collapsed group's fold row, whose key carries the group name (Month=Q1) as a coordinate, so p writes a cell at a non-item. The settability answer already has a home: project.rs::cell_id / GridLayout::folds_a_group plus the none-cats check EditOrDrill uses. Decline (status message) rather than write. Test must fail on the None-axis arm and the fold arm separately.","notes":"None-axis half fixed by PR #184 (merged). Collapsed-group half still open: assigned to the collapse follow-ups agent, which is doing it after #184 via a folds_a_group guard on the cursor path.","status":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-6p8q","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:17Z","created_by":"agent-collapse3","updated_at":"2026-09-23T04:51:03Z","started_at":"2026-09-23T04:14:58Z","lease_expires_at":"2026-09-23T04:19:58Z","heartbeat_at":"2026-09-23T04:14:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-enz6","title":"[bug] Enter-to-advance onto a collapsed group's row opens the editor and commits a write to the group coordinate","description":"Found planning improvise-6qet (not fixed there; outside its ground). Since 6qet a collapsed group is ONE data row whose tuple is the group name (Month=Q1), an aggregate with no record (context/code/core.md §14). EditOrDrill declines on GridLayout::folds_a_group, but CommitAndAdvance (command/cmd/commit.rs ~l.713) re-enters edit through the EnterEditAtCursor effect, which has no such guard. So in Editing, Enter that advances the cursor onto a fold opens the editor there, and the next commit writes SetCell with Month=Q1 — a coordinate that is not an item. Fix belongs with the edit-entry path: ask folds_a_group (the one home) rather than re-deriving 'is this a group row'. Test must seat the fold off row 0 and advance onto it through apply_effects.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:05:58Z","created_by":"agent-collapse3","updated_at":"2026-09-23T04:05:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l4mp","title":"PasteCell writes SetCell onto an aggregate cell: a None-axis aggregate or a collapsed group's fold","description":"Found planning improvise-6qet. PasteCell (command/cmd/cell.rs ~l.170) emits SetCell(self.key, value) with no aggregate check. That was already true for a cell aggregated over a regular category on Axis::None (the write lands on a partial key); since 6qet it also reaches a collapsed group's fold row, whose key carries the group name (Month=Q1) as a coordinate, so p writes a cell at a non-item. The settability answer already has a home: project.rs::cell_id / GridLayout::folds_a_group plus the none-cats check EditOrDrill uses. Decline (status message) rather than write. Test must fail on the None-axis arm and the fold arm separately.","notes":"None-axis half fixed by PR #184 (merged). Collapsed-group half still open: assigned to the collapse follow-ups agent, which is doing it after #184 via a folds_a_group guard on the cursor path.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:17Z","created_by":"agent-collapse3","updated_at":"2026-09-25T01:00:02Z","closed_at":"2026-09-25T01:00:02Z","close_reason":"Landed in #184 (on gh/main): p declines aggregates","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-enz6","title":"[bug] Enter-to-advance onto a collapsed group's row opens the editor and commits a write to the group coordinate","description":"Found planning improvise-6qet (not fixed there; outside its ground). Since 6qet a collapsed group is ONE data row whose tuple is the group name (Month=Q1), an aggregate with no record (context/code/core.md §14). EditOrDrill declines on GridLayout::folds_a_group, but CommitAndAdvance (command/cmd/commit.rs ~l.713) re-enters edit through the EnterEditAtCursor effect, which has no such guard. So in Editing, Enter that advances the cursor onto a fold opens the editor there, and the next commit writes SetCell with Month=Q1 — a coordinate that is not an item. Fix belongs with the edit-entry path: ask folds_a_group (the one home) rather than re-deriving 'is this a group row'. Test must seat the fold off row 0 and advance onto it through apply_effects.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:05:58Z","created_by":"agent-collapse3","updated_at":"2026-09-24T21:45:55Z","closed_at":"2026-09-24T21:45:55Z","close_reason":"Landed in main via #277 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x1uk","title":"AGENTS.md bd-managed Session Completion block prescribes git merge origin/main and a bare git push (the #145 trap)","description":"AGENTS.md's bd-generated block (\u003c!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f --\u003e ... END) contains a \"Session Completion\" protocol. It says `git merge origin/main`, then a bare `git push`, and that \"work is NOT complete until git push succeeds\".\n\nFor a wave agent's PR branch, both commands are wrong in this repo:\n- `git merge origin/main` is the PR #145 trap. origin is a mirror that runs ahead of gh/main, so it pulls unmerged commits into a branch based on gh/main. AGENTS.md \"Shipping a wave\" already says this in prose, which leaves the block contradicting its own file.\n- A bare `git push` is refused, because a branch cut from gh/main tracks gh/main. The right form is `git push gh HEAD:refs/heads/\u003cbranch\u003e`.\n\nIt was not fixed by hand because the text sits inside bd's generated, hash-stamped markers. A `bd setup`/upgrade regeneration would silently revert a hand edit, and a hand edit also breaks the recorded hash. Mitigation landed in the wave skill: .agents/skills/wave/agent-rules.md, \"Base, branch, push\" rule 5, tells wave agents the block does not apply to them.\n\nFix options:\n(a) Configure bd with a custom recipe or profile whose template names gh/main and an explicit refspec (see `bd setup --add`, `bd setup -o`), then regenerate.\n(b) Switch the block to a profile that omits the push protocol. The `minimal` profile description in CLAUDE.md's copy already defers git policy to active instructions.\n(c) Upstream: make the template's remote and branch configurable.\n\nAcceptance: the Session Completion text that bd writes into AGENTS.md no longer prescribes `git merge origin/main` or a bare `git push` for PR branches, and survives regeneration.\n","status":"closed","priority":2,"issue_type":"task","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:49Z","created_by":"agent-tooling2","updated_at":"2026-09-23T06:29:42Z","started_at":"2026-09-23T05:01:10Z","closed_at":"2026-09-23T06:29:42Z","close_reason":"Fixed in PR #201, merged after adversarial review (31b5acd, then the delta at 141be9f). Follow-ups: improvise-28ri, improvise-oga4, improvise-bf0q.","comments":[{"id":"c6c76952-d674-540c-8e6b-7bb3f92de1be","issue_id":"improvise-x1uk","author":"agent-tooling2","text":"The wave-skill mitigation is now agent-rules.md 'Base, branch, push' rule 6, not rule 5. improvise-uj4k added a base-check rule at position 1.","created_at":"2026-09-23T04:15:20Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-j4qt","title":"A read-only browser tab accepts edits, paints them, and loses them","description":"Found while re-checking improvise-i0v9 (agent-webpage, 2026-09-22, at 942211b). Measured in Chromium AND Firefox, two tabs in one context on one document: the second tab opens read-only (correct, 1wft). Pressing i, typing 4242, Enter in the second tab APPLIES the edit to its in-memory App and paints it (18,400.00 -\u003e 184,004,242.00), and only then does the persist fail, replacing the read-only status with 'NOT SAVED ... Cannot write ...: another tab or window is holding this document'. The first tab never sees the edit. After the first tab closes, a further edit in the second tab still reports NOT SAVED (no upgrade path; that half is i0v9's gap 2). So 'read-only' is a claim about storage, not about the session: the tab is a local scratch copy that looks writable. Candidate fix: in a read-only boot the worker refuses Command::Model frames (a Rejection to the asker, as ADR 001 already routes refusals) so the optimistic fold rolls back (improvise-nxah) and nothing un-persistable is ever painted. That needs a Rejection variant in improvise-protocol/src/basis.rs, which is why it was not done in the i0v9 lane.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:58:55Z","created_by":"agent-webpage","updated_at":"2026-09-23T04:36:06Z","started_at":"2026-09-23T04:04:43Z","closed_at":"2026-09-23T04:36:06Z","close_reason":"PR #177 MERGED (confirmed at ef7775e), adversarially reviewed (merge once gh/main was merged in and CI was green, which it now is: 13/0, MERGEABLE/CLEAN). A read-only browser tab (another tab holds the OPFS handle) refuses every Command::Model with Rejection::ReadOnly before the host sees it, so an unsaveable edit is never applied or painted. The reviewer's stronger rival (send the frame through the host, then append the rejection) fails both the Rust test and the Playwright grid assertion on the painted value alone. A mixed frame is refused whole. Firefox passes (run locally by the reviewer; CI had not run at all while the PR conflicted).","labels":["browser","opfs"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ih3k","title":"kb completeness guard cannot see one of two clauses sharing a key (work_done/3, decision/3, work_followup/3)","description":"Found reviewing PR #166 (improvise-usus) at a55b3c7.\n\n`kb-merge.pl complete` compares HEAD and the candidate as SETS of keys (`ground_keys` uses `sort/2`, which dedups). `work_done/3`, `decision/3` and `work_followup/3` are keyed on their first two arguments (`key_arity`) but are NOT `functional/1`, so `kb_consistent` accepts two clauses sharing a key with different third arguments. `kb add` refuses that on one branch, but two branches that each record `work_done(T, bug_fix, ...)` for the same ticket merge into a HEAD holding both (a git conflict at the end of the work_done block whose natural resolution is keep-both). Once HEAD has such a pair, losing either clause from the working tree is invisible to the guard.\n\nReproduced (fixture copies only, via KB_FILE / KB_BASELINE):\n- baseline = HEAD kb.pl + `work_done('improvise-zzzz',bug_fix,'first fix, merged from branch A')` + `work_done('improvise-zzzz',bug_fix,'review fix, merged from branch B')`. `scripts/kb check` on it: `kb_consistent: TRUE`.\n- working copy = the same minus the B clause.\n- `KB_FILE=copy KB_BASELINE=baseline scripts/kb add \u003ca new doctrine\u003e` -\u003e exit 0, `kb-merge: complete -- baseline 811, result 811, 0 removal(s) recorded`. The B clause is gone and the message reports equal totals.\n- Control: dropping a uniquely keyed clause is refused (kb-test case 18; also run by hand).\n\nNo such pair exists in HEAD today (checked all 809 clauses), so this is latent. Possible shapes of a fix: compare keys as a multiset (msort, and require each key's multiplicity in the candidate to be at least HEAD's, less ledger entries), or compare whole clauses (variants) for predicates that are not functional.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-kbguard2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:56:07Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:29:53Z","started_at":"2026-09-23T04:01:20Z","closed_at":"2026-09-23T04:29:53Z","close_reason":"PR #179 MERGED (confirmed at 9472989), adversarially reviewed (merge); CI 14/0 including the kb job on Linux with GNU stat. ih3k: the completeness guard counts clauses PER KEY, so losing one clause of a pair that shares a key is refused. yea8: the kb rm ledger carries a stamp (sha256 + inode + ns-mtime) of the file its write published, and any rewrite outside the tool voids it. A content hash alone would not work: kb.pl is canonical, so grep -v of a clause is byte-identical to kb rm output. In the reviewer's repro the in-place rewrite even keeps the inode, so the mtime is the part doing the work. za97: the refusal no longer offers committing the loss, and the kb skill documents the guard. Follow-up: improvise-4zei (P4, case 25 cannot see a content-only stamp; case 24 still guards it).","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -188,46 +266,46 @@ {"_type":"issue","id":"improvise-90ea","title":"fmt-workspaces-test never dirties the root package: a script that skips improvise's own src/ passes 17/17","description":"Found by post-merge adversarial review of PR #159 (improvise-b4ks), head 3d143011. This is a fourth fixture vacuity in scripts/fmt-workspaces-test.sh, the same shape as the three its evidence records.\n\nThe fixture's outer root is a package (`root-pkg`) as well as a workspace, which is right. But no case ever dirties the ROOT PACKAGE's own `src/lib.rs`. Only `a/src/lib.rs` (a member) and `nested/src/lib.rs` get dirtied. So a script that formats every member EXCEPT the root package passes the whole suite. In this repo the root package is `improvise`: `src/`, `tests/`, `examples/`, the whole TUI.\n\nCONFIRMED at 40b8913. Rival injected into members_of's python:\n names = [p[\"name\"] for p in doc[\"packages\"] if len(doc[\"packages\"]) == 1 or os.path.dirname(p[\"manifest_path\"]) != doc[\"workspace_root\"]]\n- `./scripts/checkers.sh fmt-workspaces` -\u003e 17 passed, 0 failed (exit 0).\n- `fmt-workspaces.sh --list` shows `improvise` missing from the outer root.\n- A misformatted fn appended to src/ui/text.rs was NOT reported by the gate. Control: with the rival reverted, the same fault IS reported (`Diff in .../src/ui/text.rs:72`).\nAll reverted.\n\nFix: add a case that dirties `$work/repo/src/lib.rs` and requires it to be named (exit 1).\n\nChecked and held: the harness-lint job's `cargo fmt -p opfs-contract -- --check` (working-directory harness/opfs-contract) and this script check the same crate under the same rule. Same stable toolchain, no rustfmt.toml anywhere, so the check is redundant but consistent.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-tooling2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:46:48Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:19:30Z","started_at":"2026-09-23T03:49:28Z","closed_at":"2026-09-23T04:19:30Z","close_reason":"PR #170 MERGED (confirmed), adversarially reviewed (merge after the ximf one-line fix, which was made); CI green at d11d2ac per pr-watch --expect. scripts/fmt-workspaces.sh: the fixture now misformats EVERY package it finds (discovered independently of the script) and requires each to be named exactly once, and the gate itself refuses (exit 2) if any [package] in the tree belongs to no workspace root. That structural check is what stops a fifth fixture gap; the reviewer added a fourth fixture package and it got its own case automatically. usage() now says exit 2 means NO VERDICT was produced.","labels":["adversarial-review","ci","pr-159","testing","tooling"],"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":"closed","priority":2,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T03:38:04Z","created_by":"agent-review-cmd","updated_at":"2026-09-23T05:38:01Z","started_at":"2026-09-23T04:38:07Z","closed_at":"2026-09-23T05:38:01Z","close_reason":"Fixed in PR #193, merged as a2f3c8d. Adversarial review passed at f8f4dd5; later changes were a gh/main merge touching evidence only, plus a docs correction pointing at improvise-lxg0.","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.\nIMPLEMENTED (agent-collapse3, branch fix/6qet-collapse-row). PART 2 NOT ADDED: nothing binds z as a Binding::Sequence, and if something did, both toggles resolve against one pre-batch context and name the same group, so the pair is a no-op, which is what z z means. No reachable case. Follow-ups filed: improvise-enz6 (CommitAndAdvance onto a fold), improvise-l4mp (PasteCell onto an aggregate), improvise-b5zh (close_pending_run arm now unreachable). Closes improvise-6eqi's concern: every toggle test starts off data row 0.","status":"closed","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-23T04:37:06Z","started_at":"2026-09-23T03:11:52Z","closed_at":"2026-09-23T04:37:06Z","close_reason":"PR #176 MERGED (confirmed at 216b148), adversarially reviewed (merge) and re-reviewed after a real browser-harness failure (a Playwright spec pinned the old collapsed-group row count; fixed, with a new assertion that the collapsed row is not editable). This implements the user's ruling that a collapsed group becomes ONE data row whose cell is an aggregate over the group, so the cursor lands on the collapsed group by arithmetic and z is an involution again. GridLayout::folds_a_group is the one predicate, asked by project::cell_id (CellId::Aggregate) and EditOrDrill. The toggle is emitted before the cursor placement, with the landing row computed from the layout after the toggle. Every toggle test now starts off row 0, which closes 6eqi. Follow-ups from review: improvise-gw18 (\u003e drills into a collapsed row, and o then writes a non-item coordinate), improvise-eccl (a ratio formula on the collapsed row shows 1.00 where None-axis shows 0.50), improvise-f2oo (hidden members make the total move on z), improvise-yk2s (H on a collapsed row saves a phantom hide), plus enz6, l4mp's collapsed half and b5zh.","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-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","dependencies":[{"issue_id":"improvise-obtu","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:04Z","created_by":"Edward Langley","metadata":"{}"}],"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":"closed","priority":2,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T01:36:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:58Z","started_at":"2026-09-23T04:38:25Z","closed_at":"2026-09-23T06:07:58Z","close_reason":"Fixed in PR #191, merged after adversarial review at 31fd2f5.","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":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-23T01:24:54Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:42:36Z","started_at":"2026-09-23T04:12:59Z","closed_at":"2026-09-23T04:42:36Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","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-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":"in_progress","priority":2,"issue_type":"task","assignee":"agent-adr006","owner":"el-github@elangley.org","created_at":"2026-09-23T00:55:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:22Z","started_at":"2026-09-25T04:45:22Z","lease_expires_at":"2026-09-25T04:50:22Z","heartbeat_at":"2026-09-25T04:45:22Z","dependencies":[{"issue_id":"improvise-ilvl","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:08Z","created_by":"Edward Langley","metadata":"{}"}],"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.\nANALYSIS 2026-09-22 by agent-helpkeys2 (NOT fixed; this needs a ruling). Read at gh/main 4d71c01.\n\nTHE DECISION IS ALREADY IN RUST. web/app.js calls preventDefault when on_key_event returns a frame, and a frame exists only when resolve() claimed the key (Local/Request/Upstream; Ignored gives none). The ONLY keybinding knowledge in the page is the extra rule `if (!e.ctrlKey \u0026\u0026 !e.altKey)` at app.js:786. So the obvious fix is to DELETE page knowledge, not add it: preventDefault whenever a frame comes back, whatever the modifiers. That fixes Ctrl-S (and Ctrl-d/Ctrl-u, which today scroll the grid AND whatever the browser does) with no protocol knowledge in the page. It is not safe to ship alone, for the reason below.\n\nTHE CLASS HAS THREE MEMBERS, not one, and they want different answers:\n\n1. PREVENTABLE chords the app binds on purpose (Ctrl-S, Ctrl-d, Ctrl-u; Ctrl-R/Ctrl-P/Ctrl-F if ever bound). Answer: the page honours the claim, per above. Rust already decides.\n\n2. UNPREVENTABLE chords (Ctrl-W, Ctrl-T, Ctrl-N, Ctrl-Shift-T/N/W, Ctrl-Tab; Ctrl-Q on Linux Chrome). Believed, NOT measured here: Chromium does not deliver these to a normal tab's keydown at all, and preventDefault cannot stop them. So no page code fixes Ctrl-W. Its consequence is also narrower than the note above suggests: `enter-mode save-prompt` is a mode `declined` refuses in the browser anyway, it is advertised nowhere the browser paints (not in advertised(), not in help pages), and the worker persists every accepted command to OPFS, so a closed tab loses nothing. The rule this member needs is a KEYMAP rule: a command bound only to a browser-reserved chord is unreachable in a browser, so it must have a second key or be one the browser declines. That is checkable in resolve.rs's tests over the shared keymap, given a named list of reserved chords (a fact about browsers, living beside BrowserKey).\n\n3. ACCIDENTAL claims through the AnyChar fallback. Keymap::lookup_local retries a modified Char without its modifiers and then falls to AnyChar, so in every text-entry mode Ctrl-C, Ctrl-V, Ctrl-R, Ctrl-L TYPE A LETTER — pinned on purpose by resolve::tests::a_modified_character_now_types_because_the_fallback_is_the_shared_one (Ctrl-x types 'x'). Today, in a browser minibuffer, Ctrl-V types 'v' AND pastes; Ctrl-R types 'r' AND reloads. Applying fix 1 alone would make it type 'v' and SUPPRESS the paste — strictly worse. So fix 1 is blocked on a ruling about member 3.\n\nTHE RULING NEEDED (the user's, not an agent's): should a Ctrl/Alt-modified character reach AnyChar at all? Options:\n (A) No: lookup_local stops retrying Ctrl/Alt-modified Chars as bare/AnyChar. Shared table, so the TUI changes too (Ctrl-x in the TUI minibuffer stops typing x — probably correct there as well). Risk: AltGr on Windows arrives as Ctrl+Alt, so international characters (e.g. @ on a German layout) would stop typing in the browser; BrowserKey would need to treat ctrl\u0026\u0026alt as AltGr and drop both. Then fix 1 is safe and the page loses its modifier rule.\n (B) Browser-only: BrowserKey (wasm-client/keys.rs) drops Ctrl-modified printable characters before resolve, the way it already drops Meta. No shared-table change; the TUI keeps today's behaviour; the page's rule moves into Rust where it is testable. Then fix 1 is safe.\n (C) Keep typing modified chars, keep the page rule, and fix only Ctrl-S by making Request outcomes claim regardless of modifiers. Smallest, but it is per-key again, which is what this ticket asks not to do.\nRecommended by this agent: (B) + fix 1 + the member-2 keymap guard; (A) is cleaner but changes the TUI and needs an AltGr decision.\n\nWhatever lands, the assertion belongs in harness/tests/shell.spec.js (gotcha 48) and needs `npm install` in harness/ first.","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-23T04:22:55Z","labels":["browser"],"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.\nANALYSIS 2026-09-22 by agent-helpkeys2 (NOT fixed; this needs a ruling). Read at gh/main 4d71c01.\n\nTHE DECISION IS ALREADY IN RUST. web/app.js calls preventDefault when on_key_event returns a frame, and a frame exists only when resolve() claimed the key (Local/Request/Upstream; Ignored gives none). The ONLY keybinding knowledge in the page is the extra rule `if (!e.ctrlKey \u0026\u0026 !e.altKey)` at app.js:786. So the obvious fix is to DELETE page knowledge, not add it: preventDefault whenever a frame comes back, whatever the modifiers. That fixes Ctrl-S (and Ctrl-d/Ctrl-u, which today scroll the grid AND whatever the browser does) with no protocol knowledge in the page. It is not safe to ship alone, for the reason below.\n\nTHE CLASS HAS THREE MEMBERS, not one, and they want different answers:\n\n1. PREVENTABLE chords the app binds on purpose (Ctrl-S, Ctrl-d, Ctrl-u; Ctrl-R/Ctrl-P/Ctrl-F if ever bound). Answer: the page honours the claim, per above. Rust already decides.\n\n2. UNPREVENTABLE chords (Ctrl-W, Ctrl-T, Ctrl-N, Ctrl-Shift-T/N/W, Ctrl-Tab; Ctrl-Q on Linux Chrome). Believed, NOT measured here: Chromium does not deliver these to a normal tab's keydown at all, and preventDefault cannot stop them. So no page code fixes Ctrl-W. Its consequence is also narrower than the note above suggests: `enter-mode save-prompt` is a mode `declined` refuses in the browser anyway, it is advertised nowhere the browser paints (not in advertised(), not in help pages), and the worker persists every accepted command to OPFS, so a closed tab loses nothing. The rule this member needs is a KEYMAP rule: a command bound only to a browser-reserved chord is unreachable in a browser, so it must have a second key or be one the browser declines. That is checkable in resolve.rs's tests over the shared keymap, given a named list of reserved chords (a fact about browsers, living beside BrowserKey).\n\n3. ACCIDENTAL claims through the AnyChar fallback. Keymap::lookup_local retries a modified Char without its modifiers and then falls to AnyChar, so in every text-entry mode Ctrl-C, Ctrl-V, Ctrl-R, Ctrl-L TYPE A LETTER — pinned on purpose by resolve::tests::a_modified_character_now_types_because_the_fallback_is_the_shared_one (Ctrl-x types 'x'). Today, in a browser minibuffer, Ctrl-V types 'v' AND pastes; Ctrl-R types 'r' AND reloads. Applying fix 1 alone would make it type 'v' and SUPPRESS the paste — strictly worse. So fix 1 is blocked on a ruling about member 3.\n\nTHE RULING NEEDED (the user's, not an agent's): should a Ctrl/Alt-modified character reach AnyChar at all? Options:\n (A) No: lookup_local stops retrying Ctrl/Alt-modified Chars as bare/AnyChar. Shared table, so the TUI changes too (Ctrl-x in the TUI minibuffer stops typing x — probably correct there as well). Risk: AltGr on Windows arrives as Ctrl+Alt, so international characters (e.g. @ on a German layout) would stop typing in the browser; BrowserKey would need to treat ctrl\u0026\u0026alt as AltGr and drop both. Then fix 1 is safe and the page loses its modifier rule.\n (B) Browser-only: BrowserKey (wasm-client/keys.rs) drops Ctrl-modified printable characters before resolve, the way it already drops Meta. No shared-table change; the TUI keeps today's behaviour; the page's rule moves into Rust where it is testable. Then fix 1 is safe.\n (C) Keep typing modified chars, keep the page rule, and fix only Ctrl-S by making Request outcomes claim regardless of modifiers. Smallest, but it is per-key again, which is what this ticket asks not to do.\nRecommended by this agent: (B) + fix 1 + the member-2 keymap guard; (A) is cleaner but changes the TUI and needs an AltGr decision.\n\nWhatever lands, the assertion belongs in harness/tests/shell.spec.js (gotcha 48) and needs `npm install` in harness/ first.","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-23T04:22:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-clso","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-udt2","title":"A status message carries no severity, so a headless refusal cannot set a non-zero exit","description":"Left open deliberately by improvise-q914/j6x8 (PR for fix/cli-honesty). The\nreasoning is context/code/cli.md section 3; this is the ticket it points at.\n\nimprovise cmd now PRINTS everything a command reports, to stderr, so a refusal\nis no longer indistinguishable from a success. What it still cannot do is put\na refusal in the EXIT CODE. Exit 1 means a parse error or an aborted batch;\na refusal that only writes a status exits 0.\n\nWHY IT WAS NOT CLOSED THERE. ViewState::status_msg is a bare String. Both\nclassifiers available in the tree are disproved by code in the tree:\n\n - \"a refusal is a batch that aborted\" -- only three effects set\n abort_effects (gotcha 21). effect::AddItem writes Workbook::add_item's\n Err straight to the status and does NOT abort, and that is q914's own\n worked example of a refusal. This classifier calls a refusal a success.\n\n - \"a refusal is anything that spoke\" -- cmd/fit.rs sets a status on\n SUCCESS, carrying the seed that reproduces the fit. effect::Save does\n too. This classifier calls a deliverable a failure.\n\nSo the CLI cannot guess, and guessing would be the adjacent-check defect\ndesign-principles section 7 names.\n\nWHAT WOULD CLOSE IT. A severity beside status_msg -- the smallest honest\nshape is probably an enum on the same write (Report | Refusal), set at the\n~60 sites that assign status_msg. That is a ViewState change, so it is a wire\nchange (gotcha 25): it must derive Serialize/Deserialize, it reaches the\nbrowser client's Snapshot, and the TUI can then colour the status line by it,\nwhich is arguably the bigger win. Scope it as a protocol ticket, not a CLI one.\n\nACCEPTANCE\n- A refusal that only writes a status exits non-zero from improvise cmd.\n- effect::Save on success and cmd/fit.rs on success still exit 0 -- these are\n the two rivals; a fix that fails either is the wrong fix.\n- tests/headless_cli.rs::a_command_that_succeeds_while_reporting_still_exits_zero\n must still pass unchanged.\n- README's CLI contract section is updated; it currently states the hole.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cli","owner":"el-github@elangley.org","created_at":"2026-09-22T15:20:31Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:42:38Z","started_at":"2026-09-23T04:13:05Z","closed_at":"2026-09-23T04:42:38Z","close_reason":"PR #185 MERGED (confirmed at 8d51ad7), adversarially reviewed (merge); CI 13/0. up8g: headless runs pin the extent to 80x24 (HEADLESS_EXTENT), so a script writes the same record whatever terminal ran it. The reviewer restored the terminal-size read and the test failed with R13 vs R40, both in a non-tty shell and under a real 50x150 pty. c5ip/udt2: the exit code reads whether the request was REFUSED (App::refuse sets a per-batch batch_refused flag, in AppSnapshot) rather than whether a chain ABORTED, so a success that aborts exits 0 and a refusal that narrates exits 1. That was the basis for improvise-cz8d, which reworks #181 on top of it. Follow-up: improvise-0v8l (23 of 27 refusal conversions are not pinned by a test, AddCategory most importantly).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j6x8","title":"improvise cmd discards every status message and exits 0, so a refusal is indistinguishable from success","description":"Found by the improvise-pv3g agent, while checking whether that P1's own fix was\nobservable through the binary the P1 was demonstrated with. It is not.\n\nrun_headless_commands (src/main.rs:331) applies effects and NEVER PRINTS\napp.view_state.status_msg. Measured on clean fixtures:\n\n document outcome stdout/stderr exit\n Score 1000/4000 fit succeeds, formula stored nothing 0\n Score 1e20/4e20 fit refused, nothing stored nothing 0\n\nSo a refused command is INDISTINGUISHABLE FROM A SUCCESSFUL ONE. The user gets\nsilence and exit 0 whether the command worked, refused, or did nothing at all.\nexit_code is set to 1 only for PARSE errors, so a refused command cannot signal\nfailure through the exit status either.\n\nWHY THIS MATTERS MORE THAN IT LOOKS: improvise-pv3g's acceptance asked for an\nactionable refusal naming the cause (\"too large for the 32-bit arithmetic ...\nscale the measure and fit that\"). That wording was written, tested and is\ncorrect -- and reaches NOBODY through `improvise cmd`. The TUI renders the status\nline, so the criterion is genuinely met there; the headless path is where it\nevaporates. This is the repo's characteristic defect one layer out: the fix is\nright and the surface that would show it is adjacent.\n\nWHY NO TEST SEES IT: a_refusal_names_what_went_wrong_and_stores_nothing asserts\nthe EFFECT's status. The effect is correct. Nothing asserts that the headless\nbinary emits it.\n\nA SECOND, SMALLER TRAP in the same path, worth fixing or documenting: `improvise\ncmd -f \u003cfile\u003e` REWRITES THE DOCUMENT EVEN WHEN THE COMMAND CHANGED NOTHING,\nexpanding 1e20 to 100000000000000000000. A fixture edited by string-replacing a\nvalue after one run silently becomes a no-op. It cost the reporting agent two\nmisleading test results before it was caught.\n\nWHAT TO DECIDE, not assume:\n - Which stream? A status message is not an error; stdout is probably right,\n with stderr for a refusal. Say which and why.\n - Should a refusal set a non-zero exit? It is the difference between `improvise\n cmd ... \u0026\u0026 next-step` working and silently proceeding on a no-op. Note this\n is a CLI contract change, so it belongs in README's CLI section too.\n - Is silence ever right? A script running a hundred commands may not want a\n hundred lines. If so the answer is a flag, and the DEFAULT should be the\n honest one.\n - `improvise script` shares this path -- check whether it has the same hole.\n\nNOTE improvise-q914 is adjacent (\"improvise cmd advertises JSON it does not\naccept, and swallows every effect error at exit 0\") and may subsume or be\nsubsumed by this. READ IT IN FULL FIRST and merge the two rather than filing a\nthird view of one problem.","notes":"FIXED in PR #131 (merge 9d2bdd3) as one fix with improvise-q914 -- see that ticket for the full reasoning, including why the stdout/stderr split I proposed in the brief cannot be built honestly and why the exit code is narrower than q914's AC. improvise script shares the hole exactly, being the same function. The per-command dirty accumulation also closes the no-op-rewrite trap noted here.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:26:41Z","created_by":"Edward Langley","updated_at":"2026-09-22T15:38:58Z","closed_at":"2026-09-22T15:38:58Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-j6x8","depends_on_id":"improvise-q914","type":"blocks","created_at":"2026-09-22T06:26:52Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-n6t8","title":"Header layout must be reserved, not fitted: the mode pill, the hint and the status all reflow","description":"USER 2026-09-22: \"the top bar jumps around a lot because the layout isn't fixed\nenough: The messages like 'the command line is not available in the browser yet'\nshould be toasts that do not cause relayout and should disappear eventually\n(maybe where the minibuffer is?)\"\n\nHOW IT WORKS TODAY, read before filing:\n - web/shell.js::composeStatus joins TWO independent facts into #status with\n \" . \": `shell` (the transport -- \"in-page worker\") and `model`\n (RenderModel::status, which is ViewState::status_msg). They are composed\n rather than overwritten deliberately, so a repaint cannot silently erase a\n connection error (gotcha 46).\n - #hint also carries variable-width content, one line per mode, off\n help::hint_line.\n - Both live in the flex \u003cheader\u003e. So the bar reflows when EITHER a status\n message arrives or the MODE changes -- the second is worth naming because it\n reflows even with no message at all.\n\nTHE TRAP IN THE OBVIOUS FIX, and it is the thing to get right:\nViewState::status_msg is STATE, not an event. web/app.js's repaint() calls\nshell.setModel(model.status) unconditionally, and repaint() runs on every\nkeydown. So a toast dismissed on a timer RESURRECTS on the next keypress, because\nnothing cleared the underlying string -- and Rust only replaces status_msg when\nsome later command sets one. A naive setTimeout(hide) produces a message that\nflickers back whenever the user does anything, which is worse than a message that\nsimply stays.\n\nSo dismissal must be keyed to MESSAGE IDENTITY (\"I have already shown this\nstring\") rather than to a timer alone, or the same string arriving genuinely\ntwice must be distinguishable from a repaint of the old one. Decide which, and\nsay so -- if two identical messages in a row should both be shown, identity alone\nis not enough and something must carry a sequence.\n\nSCOPE -- PRESENTATION ONLY, and that is a deliberate limit. Do NOT split\nstatus_msg into transient-vs-persistent in ViewState. That is a protocol change\nwith a portability argument attached (gotcha 30), the TUI shows status_msg in its\nbottom bar where it costs no layout at all, and nothing here forecloses it later.\nThe browser's problem is where it draws the string, not what the string is.\n\nTHREE THINGS THE FIX OWES:\n 1. THE HEADER MUST STOP REFLOWING ON ITS OWN, independently of toasts. #hint\n changes per mode, so a fixed/reserved layout is needed regardless. Reserving\n space is the fix; making the text shorter is not.\n 2. THE TOAST MUST NOT TAKE PART IN LAYOUT AT ALL -- out of flow, so it cannot\n push anything.\n 3. THE CONNECTION-ERROR FACT MUST SURVIVE. composeStatus exists because a\n repaint used to erase it. A toast that auto-hides must not take a transport\n failure with it: \"disconnected\" is not transient and must stay visible.\n That is the mirror of the bug gotcha 46 records.\n\nPLACEMENT: the user suggests near the minibuffer. PR #120 put the minibuffer as a\nraised bordered card over the grid, so that region is consistent -- but DECIDE\nWHAT HAPPENS WHEN BOTH ARE PRESENT. A user typing in the minibuffer who triggers\na status message must not have the prompt covered or displaced. Say what you\nchose.\n\nTESTABLE, and therefore required: harness/tests/ can assert that the header's\nbounding box does not change when a status message arrives and when the mode\nchanges (the two reflow causes above), and that the toast overlaps rather than\ndisplaces. gotcha 48: a change to web/*.js belongs in that spec directory. The\ncolour and the animation are not testable there and should be argued instead.\n\nSEQUENCING: branch ui/page-menu is in flight and owns web/app.js, web/shell.js\nand web/style.css. START THIS AFTER IT LANDS, or expect a conflict in all three.","notes":"USER 2026-09-22, widening this ticket: \"the other display annoyance is that the\nnormal/categories/etc. pill changes sizes constantly: it should have a fixed\nallocated size and not move the layout around. As a general rule, there shouldn't\nbe motion of layout elements I expect to be fixed.\"\n\nSO THIS TICKET IS NOW ABOUT HEADER LAYOUT STABILITY GENERALLY, and the toast is\none instance of it. THE RULE IS THE DELIVERABLE, not three separate patches.\n\nTHREE KNOWN MOVERS, all in the flex \u003cheader\u003e:\n 1. THE MODE PILL. Its label comes from AppMode::label() -- NORMAL, CATEGORIES,\n FORMULA, INSERT, RECORDS, ... -- and the pill is sized by its text, so every\n mode change resizes it and shifts everything after it.\n 2. #hint. One line per mode off help::hint_line, so it reflows on the same\n event as the pill, compounding it.\n 3. #status. Composed from the transport fact and ViewState::status_msg (see\n the original description).\n\nTHE OBVIOUS FIX IS THE FRAGILE ONE, AND IT WILL BE CHOSEN BY DEFAULT: a\nhardcoded min-width in ch, sized by eye to today's longest label. That silently\nresumes the jitter the day an AppMode variant with a longer label is added.\nAppMode's per-variant facts are exhaustive-match methods, so the COMPILER makes\nyou fill in label() for a new variant (repo-map, AppMode) -- but nothing anywhere\nchecks that the CSS still fits it. The gap between \"the compiler caught the Rust\"\nand \"the pixel is wrong\" is exactly where this regresses.\n\nSO THE GUARD IS THE POINT, and it is genuinely testable: a Playwright spec that\nwalks every reachable mode and asserts the header's bounding box -- and the left\nedge of the element AFTER the pill -- do not change. That catches a new long\nlabel, not just today's set. Prove it discriminates by injecting a mode label\nlonger than the reserved width and watching it fail. Without that injection this\nis a test that passes because nothing moved anyway.\n\nNote which modes a browser can actually reach: improvise-6acs and improvise-y11g\nmean some text-entry modes are not reachable there yet, so a spec that walks\n\"every mode\" may be walking four. Say how many it really covered -- an\noverstated scope is worse than a narrow one honestly described.\n\nTHE GENERAL RULE TO WRITE DOWN, since the user stated it as one: an element whose\nCONTENT varies but whose POSITION the reader treats as fixed must have its space\nRESERVED, not fitted. That covers the pill, the hint, the status, the document\ntitle and the view chip, and it is the reason a toast must be out of flow rather\nthan merely short.\nFIXED in PR #127 (merge d46ec59).\n\nTHE TICKET'S FRAMING WAS WRONG ABOUT WHICH ELEMENT COMPOUNDED, and the spec found\nit rather than the author. The ticket (and the user's report) named the mode pill.\nOn its first run the new spec failed on the header's HEIGHT: #status was\nflex: 0 1 auto beside a flex: 1 1 auto hint, so a longer hint took the shrink out\nof #status, which WRAPPED TO TWO AND THEN THREE LINES -- 52.5px to 71.0px. THE\nOLD HEADER WAS TWO LINES TALL AT NORMAL WITH NO MESSAGE AT ALL.\n\nSo reserving the pill alone would have left the bar moving and the PR would have\nshipped looking correct. Fixed with flex: 1 1 0 on the hint (zero basis, absorbs\nthe remainder, contributes nothing to overflow) and nowrap + flex: 0 0 auto on\n#status.\n\nTHE RESERVE IS A FLOOR, NOT A CAP, and that is the load-bearing choice: a width\nor max-width absorbs an over-long value inside a box that never grows, so every\nbounding-box measurement stays identical and the regression is INVISIBLE. min-width\nlets an over-long mode label grow the box, which is the only way the guard can see\nit. Proved by injection: removing the 21ch reserve fails \"the bar moved in\nCATEGORIES\" AND the 16-char check-the-checker.\n\nDISMISSAL IS KEYED ON WHAT WAS LAST ACCEPTED, not on a timer, because\nViewState::status_msg is STATE re-offered by every repaint. Consequence, stated\nrather than hidden: TWO IDENTICAL MESSAGES IN A ROW SHOW ONCE. That is forced --\nnothing beside status_msg carries a sequence, model.revision moves for a cursor\nkey as readily as for a re-issued command, and of the two readings of an ambiguous\noffer only one avoids reintroducing the reported bug. A sequence number is the\nprotocol change the scope forbade.\n\nGOTCHA 46 IS NOW STRUCTURAL RATHER THAN CONVENTIONAL. composeStatus is DELETED:\nthe transport fact and the document fact go to two ELEMENTS, not one string joined\nwith a separator, so a repaint reaches onModel and CANNOT ADDRESS onShell AT ALL.\nThe join's unit test was REPLACED, not dropped -- \"a repaint's model status cannot\nreach the transport's sink at all\", verified present by the orchestrator. A\nseparation with no guard is how one regresses into a single sink.\n\nNINE MODES WALKED, not the four I guessed: NORMAL, CATEGORIES, FORMULAS, VIEWS,\nTILES, HELP, INSERT, NEW CATEGORY, FORMULA. Excluded with reasons:\nSavePrompt/ExportPrompt/CommandMode refused by resolve::stranding, records mode\ndeclines out loud, ImportWizard has no browser entry, Quit is not a screen. Each\niteration asserts #mode BEFORE measuring, so a key that failed to reach its mode\ncannot make the comparison vacuous.\n\nMINIBUFFER AND TOAST COEXIST: #bottom-dock is position: fixed and anchored at its\nBOTTOM, stacking upward. The minibuffer's box is byte-identical with and without a\ntoast. A top-anchored stack, or an offset from the minibuffer's height, would both\nhave moved the prompt.\n\nTHE TOAST KEEPS ITS TEXT WHEN IT HIDES, deliberately: the element is the record of\nwhat was last said, and drill.spec.js's discrimination between two refusals reads\nit -- an emptied node would satisfy not.toContainText(\"aggregate\") VACUOUSLY the\nmoment the timer fired.\n\nNOT VERIFIED: prefers-color-scheme is exercised nowhere (dark was screenshotted by\nhand; pre-existing, now stated in clients.md). Colour, contrast, spacing and the\ngrouped/multi-level header DOM remain argued. Text ALIGNMENT inside a fixed box is\ninvisible to a bounding-box guard -- honest scope, not a gap. Firefox and WebKit\ndo not run header.spec.js (Chromium-only, the config's existing rule).","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:09:15Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:45:56Z","closed_at":"2026-09-22T07:45:56Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rw6l","title":"Page bar: clicking a page category should open a menu of its items, with a down-arrow affordance","description":"USER 2026-09-22, pointing at the page bar (the \"Quarter = Q1 (1/4)\" chip) on the\ndeployed page: \"The page items here (e.g. Quarter) should open a menu when clicked\nso you can pick a different option. Also have a down-arrow indicating there's a\nmenu.\"\n\nTHE GOOD NEWS, VERIFIED BEFORE FILING: almost everything needed already exists.\nThis is NOT a protocol change and needs no new derivation.\n\n - improvise_core::view::layout::PageAxis ALREADY carries\n `items: Option\u003cVec\u003cString\u003e\u003e` -- Some exactly for Axis::Page, None for a\n Filter axis, which constrains without being navigable (gotcha 27). So the\n ITEM NAMES ARE ALREADY PROJECTED: GridCache::page_coords is Vec\u003cPageAxis\u003e.\n - ModelCommand::SetPageSelection { .. } ALREADY EXISTS on the wire\n (improvise-protocol/src/reduce.rs:127), and is in records_named's match at\n :185. So the mutation half is done.\n\nTHE ONE GAP is that the browser THROWS THE NAMES AWAY. wasm-client/src/render.rs\nRenderPageAxis (:157) keeps `cat`, `selected` and `cycle: Option\u003cRenderPageCycle\u003e`,\nand RenderPageCycle (:168) is `{ at: usize, count: usize }` -- a POSITION and a\nCOUNT. That was enough for improvise-p1ym, which asked \"can [ and ] cycle\"; it is\nnot enough for \"can the user pick\", which needs the names.\n\nSo: widen RenderPageAxis to carry the item names it is already handed. That is a\npass-through, not a second derivation -- the names come from the projection, and\nnothing in the browser may compute them (design-principles section 5).\n\nTHEN THE UI, which is the actual work:\n - a down-arrow affordance on a page chip that HAS a menu. Note the affordance\n must follow `items.is_some()`, NOT the presence of a page bar entry: a Filter\n axis appears in page_coords and must NOT get an arrow, because there is\n nothing to pick. Getting that wrong offers a menu that cannot do anything.\n - click opens a menu of the items; choosing one composes\n Command::Model(SetPageSelection{..}).\n - the currently selected item should be marked in the menu.\n\nEVENT HANDLING: the user has already ruled on the shape (improvise-b8ot) -- one\ndelegated handler per surface on a stable root, not one listener per node, and\nIDs/classes/data attributes as needed to disambiguate. A menu built with per-node\nlisteners would be new work in exactly the style b8ot exists to remove. Read b8ot\nbefore starting, and prefer landing this in the shape b8ot wants rather than\nadding another per-node listener to migrate later.\n\nKEYBOARD: the menu must be operable without a mouse, and must not swallow keys\nthe grid needs. web/app.js's window keydown handler returns early when `editing`\nis true; a menu is a third mode of that kind and needs the same treatment or it\nwill eat hjkl. Say which you chose.\n\nNOT IN SCOPE, adjacent, filed separately or noted:\n - the TILE bar above it (the \"Quarter Pag\" chips) is a different widget. Its\n three-character labels come from Axis::short_label(), which exists so the\n TUI's tile bar has fixed-width columns; in a browser that reads as a\n truncation bug. Axis::plain_label() already says \"Page\". That is a separate\n change and NOT this ticket.","notes":"FIXED in PR #123 (merge 7470b61).\n\nTHE DESIGN DECISION WORTH KEEPING: `items` is relayed INSIDE `cycle`, not beside\nit. One predicate, not two. A Filter axis has no cycle, so it gets no arrow, no\nmenu, no items, is painted as a \u003cspan\u003e rather than a \u003cbutton\u003e, and\nClick::PageItem composes nothing if a click reaches it -- FOUR answers off\nPageAxis::cycle, the one home gotcha 27 already named. `items.is_some()` appears\nnowhere. That is stronger than the guard I asked for in the brief, which was a\ntest that a Filter axis gets no arrow: here the wrong state is not constructible.\n\nEvery test carries a Filter axis beside the Page one, and the pass-through fault\n(items: Vec::new()) is caught by\nrender::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to.\n\nKEYBOARD: the menu is the third state of the kind `editing` already was --\napp.js's window keydown returns early into shell.js::menuKey. Same mechanism, not\na new one, because the menu hangs over the grid and hjkl reaching through would\nscroll the thing it points at out from under it. menuKey answers EVERY key with\n`ignore` as the default (swallowed, never passed on), and count: 0 closes rather\nthan wrapping modulo zero.\n\nCODEX FOUND A REAL BUG and it was correct: the pageMenu branch originally sat\nABOVE app.js's metaKey guard, so with the menu open Cmd-R was answered `ignore`\nand then preventDefault'ed -- reload blocked -- and Ctrl-j/Alt-Home moved the\nmenu, two lines below a comment saying Ctrl/Alt stay the browser's. Fixed by\nordering plus one bail. VERIFIED BY THE ORCHESTRATOR on the merged branch: the\nmetaKey guard is above the branch, and pageMenuKeydown returns on\nctrlKey || altKey before claiming anything.\n\nBOUNDARY CROSSING, flagged and accepted: resolve.rs gained Click::PageItem and\nlib.rs a click_page_item. Unavoidable -- the ticket asks that choosing an item\nCOMPOSES SetPageSelection, the page holds no Basis, and design-principles\nsection 5 forbids it deriving one. No wire change: Command, ModelCommand and the\nframe pair are untouched. Indices, matching every existing Click variant, so the\npage never spells a category or item name.\n\nNOT VERIFIED: Firefox and WebKit run none of pages.spec.js (the config is\nChromium-only outside opfs/shell). The menu's position: fixed placement off\ngetBoundingClientRect is the part that could plausibly differ by engine.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:10Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:45Z","closed_at":"2026-09-22T06:41:45Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:44:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-oujb","title":"A release should carry the web app too: today a tag captures only the native binaries","description":"USER RULING 2026-09-22, asked whether the release workflow builds a package for the\nweb version and told it does not: \"Attach the site to releases too.\"\n\nWHAT IS TRUE TODAY. .github/workflows/release.yml has ZERO references to wasm, web,\npkg, pages or assemble -- verified by grep. It builds four native targets\n(aarch64/x86_64 x macOS/Linux) plus a shell installer, per dist-workspace.toml.\nThe web app ships by a completely separate route: ci.yml's pages-deploy, gated on\n`github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`. Continuous\ndeployment from the default branch, not a release artifact.\n\nFOUR CONSEQUENCES, none of them written down anywhere:\n - A TAG DOES NOT DEPLOY THE SITE. pages-deploy fires only on a push to main, so\n tagging leaves the site wherever main last was. Tag from main's tip and they\n coincide BY ACCIDENT; tag an older commit and they diverge silently.\n - There is no web version of a release to download, self-host or roll back to.\n - THE PAGE NAMES NO VERSION AT ALL. web/assemble.sh stamps the cache-bust token\n with $GITHUB_SHA, but nothing in web/index.html shows a version or commit, so\n a visitor cannot tell what they are running and neither can a bug report.\n - A binary user and a site user are running different code, potentially months\n apart: v0.1.0-rc2's binaries are from 2026-04-11 and the site is from whatever\n merged most recently. README's Installation section does not say so.\n\nTHE ASK: a tagged release also carries the assembled site as an artifact, so a\nrelease has a self-hostable web bundle and a rollback target.\n\nFIVE TRAPS, each of which is how this gets done wrong:\n\n1. release.yml IS AUTOGENERATED BY cargo-dist and says so on line 1. A hand edit\n is clobbered by the next `dist init`. Use dist's own supported mechanism for\n extra artifacts (see its `extra-artifacts` / custom build configuration in\n dist-workspace.toml) so the addition SURVIVES REGENERATION. If no supported\n mechanism fits, say so explicitly and argue for the hand edit rather than\n making it silently -- and leave a comment in dist-workspace.toml so the next\n `dist init` has a warning.\n2. DO NOT WRITE A SECOND WAY TO BUILD THE BUNDLE. ci.yml's `build` job carries a\n long comment explaining that everything downstream DOWNLOADS from one\n wasm-pack run precisely so \"the harness runs against the tree Pages publishes\"\n is true by construction. A release job that builds its own bundle a different\n way reintroduces exactly what that comment prevents. Use web/build.sh and\n web/assemble.sh, the same scripts a developer runs.\n3. THE CACHE-BUST STAMP TAKES AN ARGUMENT AND THE RIGHT ONE DIFFERS. CI passes\n $GITHUB_SHA; a local run defaults to `dev`. A release should almost certainly\n pass the TAG. Decide, and note scripts/assemble-stamp.sh verifies each stamped\n reference independently -- check it still passes with whatever you choose.\n4. THE RELEASE JOB NEEDS A TOOLCHAIN IT DOES NOT HAVE: wasm32-unknown-unknown,\n a PINNED wasm-pack (ci.yml pins v0.15.0 and explains that pinning is what makes\n a local measurement evidence about a CI run), and node for the prose site.\n Reuse those pins rather than picking fresh ones.\n5. wasm-opt REJECTS LLVM's memory.copy WITHOUT --enable-bulk-memory, and the\n worker bundle ALSO needs --enable-nontrapping-float-to-int because it carries\n the whole App and so chrono's date arithmetic (gotcha 45). Those live in the\n crates' Cargo.toml metadata; do not re-specify them.\n\nALSO IN SCOPE, because it is the cheap half of the same problem: GIVE THE PAGE A\nVISIBLE VERSION OR COMMIT. A site that cannot say what it is makes every bug\nreport about it unanswerable. Small, and worth more than the tarball.\n\nDECIDE AND STATE, do not default: whether tagging should ALSO deploy Pages, or\nwhether the site keeps tracking main and a release merely captures a copy. The\nuser chose \"attach the site to releases\", which does not by itself answer this.\nBoth are defensible; supporting both by accident is not.\n\nNOT IN SCOPE: bumping a version or cutting a tag (improvise-3tj / improvise-l36,\nboth need the user), and RELEASING.md, which improvise-gx5d owns on another branch.","notes":"USER RULING 2026-09-22, settling the open decision this ticket flagged: \"I like\nPages showing the latest main for now.\"\n\nSO: pages-deploy is UNCHANGED. It keeps its `github.event_name == 'push' \u0026\u0026\ngithub.ref == 'refs/heads/main'` gate; a tag does not deploy the site. A release\nmerely CAPTURES a copy of the assembled site as an artifact.\n\nNote the words \"for now\". This is a current preference, not a permanent\narchitecture decision, and whoever reads it later should be able to tell the\ndifference. Do not record it as settled forever.\n\nTHREE THINGS THAT FOLLOW, and they change the emphasis of the work:\n\n1. THE TWO LINEAGES DIVERGING IS NOW DELIBERATE, so it gets DOCUMENTED rather than\n fixed. A binary user runs whatever was tagged; a site visitor runs latest main.\n One sentence in README's Installation section.\n\n2. THE VERSION STAMP IS THE MOST VALUABLE PART OF THIS TICKET, not the tarball.\n With the lineages deliberately different, a page that cannot say what it is\n makes every bug report about it unanswerable. web/index.html names no version\n at all today. If only one thing lands from this bead, it is the stamp.\n\n3. THE ARTIFACT'S PURPOSE IS NARROWER THAN IT SOUNDS. With Pages tracking main,\n the release tarball is NOT what anyone visits -- it is a self-hostable copy and\n a rollback target. Saying \"the web version of v0.1.0\" invites a reader to\n assume the tarball is what is deployed, which is false and costs an afternoon.\n State the narrow claim.\n\nThe cache-bust token question narrows too: since pages-deploy is untouched, the\nrelease capture is the only new caller of web/assemble.sh, so the tag is the\nobvious token. Check scripts/assemble-stamp.sh still passes with it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:31:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:31:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gx5d","title":"The tag-push release workflow: diagnosis, a preflight guard, and the missing procedure","description":"USER 2026-09-22: \"we need to fix the tag push release workflow\" ... then, on being\nshown the diagnosis: \"do nothing for now, just make sure the workflow will work\"\nand yes to a preflight guard.\n\nTHE WORKFLOW WAS NEVER BROKEN, and the evidence is stronger than a local check.\nv0.1.0-rc1 and v0.1.0-rc2 (2026-04-11) BOTH have full published GitHub Releases:\ntarballs for all four targets, their .sha256 files, dist-manifest.json,\nsource.tar.gz and improvise-installer.sh. So the workflow has run end to end and\nproduced everything it is supposed to produce.\n\nWHAT ACTUALLY HAPPENED: v0.1.0-rc3 was pushed to gh, origin and gitea while every\ncrate still said 0.1.0-rc2. dist matches the tag against the ROOT PACKAGE version\n(dist-workspace.toml says members = [\"cargo:.\"], so only the root package counts\nand gp-tree sitting at 0.1.0 is not a finding). It refused with \"This workspace\ndoesn't have anything for dist to Release!\" and named the right answer itself, and\nthe four jobs behind `plan` skipped.\n\nReproduced both directions locally: `dist plan --tag=v0.1.0-rc2` gets past the\ncheck; `--tag=v0.1.0-rc3` gives the CI error verbatim.\n\nA CORRECTION WORTH KEEPING, because it nearly became a false fact in context/: I\nfirst concluded rc1/rc2 had never triggered the workflow, inferring it from an\nempty `gh run list`. GitHub Actions logs AGE OUT AT 90 DAYS and those tags are\nfive months old. An absent run and a discarded run look identical from the run\nlist, which is the same shape as doctrine(a_missing_loose_ref_is_not_a_deleted_branch).\nCheck `gh release view \u003ctag\u003e --json assets` -- the artifacts outlive the logs.\n\nTHE REAL ROOT CAUSE IS DOCUMENTATION. There is no release procedure anywhere:\n - .github/workflows/release.yml is cargo-dist's autogenerated file -- a\n mechanism, not a procedure.\n - README section Installation describes the current state and is accurate.\n - context/plan.md Phase 3 has one-time SETUP steps from the launch plan,\n including \"test the workflow by tagging v0.1.0-rc1 ... Delete the rc tag\n afterward\" -- which was not done; all three rc tags are still on all three\n remotes.\nNothing anywhere says BUMP THE VERSION BEFORE YOU TAG.\n\nIN FLIGHT on branch fix/release-preflight: scripts/release-preflight.sh (refuses a\ndirty tree, compares tag to root version, runs `dist plan` as a second opinion and\nsays when it could not), its check_the_checker, a CI step in the `test` job, the\nreasoning in context/code/tooling.md, and a short RELEASING procedure.\n\nNOTE THE TEST LESSON, which is the transferable part: the preflight's first test\nasserted only \"did it refuse\", and every negative case still passed with the tag\ncomparison replaced by `if false;` -- because the script refuses a DIRTY WORKING\nTREE first and this repo's tree is almost always dirty from bd's auto-export. Four\nof five checks were answering \"is the tree dirty\" while claiming to answer \"does\nit catch a bad tag\". It now runs against a clean throwaway checkout AND asserts the\nrefusal names the version mismatch.\n\nNO VERSION IS BUMPED AND NO TAG IS CUT. Cutting v0.1.0 is improvise-3tj and\npublishing is improvise-l36; both need the user.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:26:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:26:50Z","dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-owep","title":"Path traversal: a connected client can make the server read any file it can read (LoadModel)","description":"USER RULING 2026-09-22: \"We'll rely on sufficient containerization, but we should\nprevent path traversal attacks mainly: the server should have a documents\ndirectory and make sure the path canonicalizes to that directory.\"\n\nTHIS IS LIVE TODAY, NOT A FUTURE CONCERN. Verified by the orchestrator while\nscoping the ruling:\n\n - ModelCommand::LoadModel(PathBuf) is ON THE WIRE (protocol/src/reduce.rs:159).\n - effect::LoadModel (command/src/ui/effect.rs:914) calls\n persistence::load_from(\u0026*app.storage, \u0026self.0) with the CLIENT'S path.\n - PhysicalFs (io/src/storage.rs:94) has NO root and NO confinement:\n File::open(path) and File::create(path) on whatever arrives.\n - improvise-ws-server builds its App with App::new(workbook, path), which\n supplies that unconfined PhysicalFs.\n\nSo ANY CONNECTED WEBSOCKET CLIENT CAN ASK THE HOST TO READ ANY FILE THE SERVER\nPROCESS CAN READ. A .improv-shaped file loads wholesale and is then projected\nback to the client. A file that does NOT parse is still a leak risk: the error\ngoes into view_state.status_msg as \"Load error: {e}\", and pest errors quote the\noffending input.\n\nWRITE IS NOT YET CLIENT-REACHABLE -- Save/SaveAs are ViewOnly and off the wire --\nBUT IT IS ABOUT TO BE. improvise-y11g (the : command line, a core feature per the\nuser) reaches `:w \u003cpath\u003e`, and improvise-2mqz adds the export/download frames.\nLanding either without this is how arbitrary file WRITE ships.\n\nPAIR WITH improvise-kc7n: the Origin check answers what PAGE is driving a client,\nnever WHO is connecting, and improvise-kpnr (authentication) is open. Confinement\nis the control that does not depend on either.\n\nWHERE IT BELONGS: THE BACKEND, NOT THE CALL SITES. Gotcha 2 -- every document read\nor write goes through App::storage, and the host picks the backend. So this is a\nROOTED PhysicalFs (e.g. PhysicalFs::rooted(dir)), and every caller is covered by\nconstruction rather than by each remembering to check. That is\ndoctrine(an_invariant_is_made_unrepresentable_not_validated), the same answer\nprune_axis_items and gp-tree's Row\u003cT\u003e got.\n\nCRITICALLY, THIS IS A PROPERTY OF THE SERVER, NOT OF Storage. A TUI user may open\nany file they own; confining App::new would be WRONG. The server constructs the\nrooted backend; the TUI keeps the unrooted one. Gotcha 2 already says the host\npicks, so this fits without a trait change.\n\nSIX IMPLEMENTATION TRAPS, each of which is the standard way this fix is got wrong:\n\n1. std::fs::canonicalize REQUIRES THE PATH TO EXIST. A write to a NEW document\n fails it. Canonicalize the PARENT and join the final component -- and reject a\n final component that is itself `..` or contains a separator.\n2. REJECT, DO NOT SANITIZE. Stripping or collapsing `..` is the classic broken\n fix; `....//` and percent/unicode variants defeat it. Canonicalize, then\n compare.\n3. COMPARE COMPONENT-WISE, NOT AS STRINGS. `/srv/docs-evil`.starts_with(\"/srv/docs\")\n is TRUE as a string and false as a path. Use Path::starts_with, which is\n component-wise, and canonicalize the ROOT once at construction (a relative or\n symlinked root defeats the comparison otherwise).\n4. SYMLINKS: canonicalize resolves them, so a link inside the directory pointing\n out IS caught -- but only because the check runs on the resolved path. Do not\n \"optimise\" it into a lexical normalisation.\n5. TOCTOU IS A STATED RESIDUAL, NOT A SOLVED PROBLEM. Between the check and the\n open, a component can be replaced. openat2(RESOLVE_BENEATH) is the real answer\n and portable Rust does not have it. The user has ruled that containerization\n carries this; SAY SO in the code's context/ prose rather than implying the\n check is total.\n6. ABSOLUTE PATHS INSIDE THE ROOT must be allowed or the feature is unusable, and\n absolute paths outside it refused -- so the rule is \"canonicalizes under the\n root\", never \"is relative\".\n\nWHAT THE SERVER'S CLI GAINS: a --documents-dir (name it as you like), replacing or\nconstraining the current `files: Vec\u003cPathBuf\u003e` list. Decide and state whether the\nexisting multi-file form survives as \"these files, which must be under the root\"\nor is replaced by \"this directory\". Both are defensible; silently supporting both\nis not.\n\nTESTS -- and the check_the_checker discipline applies, because a confinement test\nthat passes against an unconfined backend is worse than none:\n - ../ escapes, absolute escapes, a symlink inside the root pointing out, a root\n that is itself a symlink, and the `/srv/docs-evil` string-prefix case.\n - A NEW file inside the root must be WRITABLE -- the case trap 1 breaks.\n - Drive it through ModelCommand::LoadModel over Sessions, not just against\n PhysicalFs directly: the vulnerability is reachable from the wire and the test\n should be too.\n - INJECT THE FAULT: point the rooted backend's root at `/` and watch the escape\n tests fail. A confinement test that cannot fail is the characteristic defect\n of this repo.\n - Consider whether io/src/storage.rs::contract::storage_contract should gain a\n confinement clause. Note OpfsFs is confined by the browser already and\n MemoryFs is a map, so this may be a PhysicalFs property rather than a contract\n one -- decide explicitly and write down which.","notes":"DEFERRED BY USER RULING 2026-09-22: \"The server isn't running anywhere yet so this\ncan be deferred.\"\n\nThat is an EXPOSURE judgement, not a correctness one. Everything in the analysis\nabove stands: the path really is client-supplied, PhysicalFs really is unrooted,\nand the read really would succeed. What is absent is a deployment -- nobody is\nrunning improvise-ws-server, so there is no process for a client to reach and no\nfilesystem to read. Priority dropped P0 -\u003e P2 and the bead set BLOCKED so it stays\noff `bd ready`, NOT closed.\n\nTHE DEFERRAL IS CONDITIONAL, AND THE CONDITION IS ALREADY WRITTEN DOWN AS A\nDEPENDENCY. improvise-y11g (the : command line) and improvise-2mqz (SaveAs /\nExportCsv) are both `blocked-by` this ticket, and that edge is now MORE load-\nbearing rather than less: it is what stops the exposure arriving before the fix.\nDo not remove those edges to unblock either ticket. If one of them becomes urgent,\nthis one comes with it.\n\nTHREE THINGS THAT REOPEN IT, so the condition is testable rather than remembered:\n 1. improvise-ws-server being deployed or demoed anywhere reachable, including a\n tunnel for a Show HN.\n 2. improvise-y11g or improvise-2mqz landing, either of which makes a\n client-supplied path reach a WRITE.\n 3. Any new ModelCommand or frame that carries a path.\n\nWHAT DOES NOT REOPEN IT: the browser worker deployment. Its Storage is OpfsFs,\nconfined by the browser to the origin's private filesystem, and the document is\nthe user's own. This ticket is about improvise-ws-server only.\n\nNOTE FOR WHOEVER PICKS IT UP: the analysis above was written against a live\nreading of the tree at d17ad0f. Re-verify rather than trust it -- a deferred\nsecurity finding whose premises have quietly changed is worse than an open one,\nbecause the priority says it was judged and the judgement may no longer hold.","status":"blocked","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:24:28Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:25:03Z","labels":["protocol"],"dependency_count":0,"dependent_count":2,"comment_count":0} -{"_type":"issue","id":"improvise-0w7y","title":"Browser UI polish: dismissable banner, a minibuffer a web user will see, and more contrast","description":"USER, 2026-09-22, looking at the deployed page at fiddlerwoaroof.github.io/improvise/app/:\n\n 1. \"I just noticed the minibuffer is at the bottom of the screen, that's a bit\n surprising and not really 'in your face' for a web page.\"\n 2. \"we need to do a frontend design polish for the UI. The general aesthetics\n are pretty good, but it should 'pop' a bit\"\n 3. \"we should make the giant 'Everything runs here in this tab ...' thing\n dismissable\"\n\nWHAT THE SCREENSHOT SHOWS. The banner is ~10 dense lines of prose ABOVE THE FOLD,\npushing the tile bar, category tree, formula panel and grid down. It explains\n?doc=, ?seed=, ?ws=, wss:// vs ws:// and reverse proxies -- reference material,\non first paint, every visit. The minibuffer was showing \"Display a menu\" in small\ntext at the very bottom-left.\n\nSCOPE IS PRESENTATION ONLY. RenderMinibuffer is built in wasm-client/src/render.rs\nfrom state.view.mode.minibuffer(), and both front ends read the same semantic\nvalue. Moving or restyling it must NOT add a second derivation of what it says\n(design-principles section 5; improvise-edp is the worked example). The bottom\nline stays right for the TUI -- this is about the web page.\n\nNOT THIS TICKET: making the minibuffer REACHABLE. That is improvise-6acs (verify\nthe \"typing blind\" report) and improvise-y11g (the : command line). Seeing it and\nreaching it are different bugs and the tracker should not let one stand in for\nthe other.\n\nTHE CONSTRAINT THAT SHAPES THE WORK: harness/tests/shell.spec.js is the only thing\nthat executes web/*.js, and it CANNOT see CSS or the grouped/multi-level DOM\n(gotcha 48). So a dismissed banner surviving a reload is testable and most of the\npolish is not -- which means the untestable part must be ARGUED, and the split\nbetween the two must be stated rather than blurred.\n\nBanner dismissal is per-viewer browser storage, which is what localStorage is\nlegitimately for: wrap every read and write in try/catch and render correctly when\nit throws or returns nothing.\n\nIN FLIGHT on branch ui/shell-polish.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:27Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6acs","title":"Verify the browser minibuffer report: painting is tested, reaching the mode is not","description":"USER REPORT 2026-09-21: \"the 'minibuffer' for things like typing in a new category\nname, etc. doesn't actually show up anywhere so you're typing blind.\"\n\nTHIS WAS NEVER FILED. It is filed now as a VERIFY-FIRST ticket, because the obvious\ndiagnosis is wrong and the real cause may already be fixed.\n\nWHY IT IS NOT A PAINTING BUG. web/app.js (:247-251) paints the minibuffer, and\nrender.rs::every_minibuffer_mode_paints_what_is_being_typed asserts all four\ntext-entry modes paint their prompt and the typed value. That test exists because\npainting WAS the bug once (improvise-fdt6, evidence in\ncontext/test-evidence/clients.md) and it was fixed.\n\nWHY THE TEST CANNOT SEE THE REPORTED SYMPTOM, which is the interesting part: it\nfolds Command::ChangeMode(mode) DIRECTLY. It never presses a key. So it proves the\npaint works GIVEN the mode, and says nothing about whether a keypress can REACH the\nmode. That is this repo's characteristic defect -- a test that passes while the\nthing it names is broken -- sitting in the exact place the symptom points.\n\nSO THE HYPOTHESIS TO TEST IS REACHABILITY, not rendering: does a browser keypress\nactually put the client into AppMode::CategoryAdd? resolve.rs::stranding() refuses\nonly SavePrompt, ExportPrompt and CommandMode, so category-add is not stranded --\nbut `search-or-category-add` sat on the_command_names_that_still_need_the_host_are_these\nuntil PR #114 moved it off (improvise-koh6). The report predates that merge.\n\nSO IT MAY ALREADY BE FIXED. Verify against current main FIRST and close on evidence\nif so -- do not build anything before reproducing.\n\nIF IT REPRODUCES: the fix is whatever makes the key reach the mode, and the test\nthat earns its place is one that goes from a KEYPRESS to a painted minibuffer, not\nfrom a folded ChangeMode. Add it beside the existing one rather than replacing it;\nthey answer different questions and both are worth having.\n\nIF IT DOES NOT REPRODUCE: say so on the ticket with the keypress you used, and\nstrengthen the existing test anyway -- a keypress-to-paint test is the guard that\nwould have caught this class, and its absence is why a user found it instead.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:14:30Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:14:30Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1ohv","title":"add-record-row cannot be named on the wire: a client cannot create a record","description":"Found by the improvise-h3y7 completeness audit.\n\nAddRecordRow (cmd/grid.rs:492) builds a CellKey from the active view's\nfiltering_cats() page selections (gotcha 27: Page union Filter, NOT\ncategories_on(Axis::Page)) and writes SetCell(key, Number(0.0)).\n\nA client cannot compose it, for the same reason the coordinate rename cannot be\ncomposed: ModelCommand::SetCell takes a RecordCell whose RecordHash is an opaque\nsurrogate the server resolves against cells that ALREADY EXIST (App::resolve_record,\nui/app.rs:516). The record this command creates does not exist, so there is nothing\nto name it with.\n\nThe client DOES have the operands -- GridCache::page_coords carries the page\nselections (gotcha 33), and page_coords is framed by nothing and survives a reshape.\nWhat it lacks is a variant that TAKES them.\n\nWHY THIS IS NOT A ONE-LINE ADDITION. A ModelCommand that names coordinates rather\nthan a surrogate is a NEW NAMING MODE in the vocabulary. ADR 002 contemplated two:\n\"names its target by name\" (a category, an item, a view) and \"names a record that\nalready exists by a position-free surrogate\". A third -- \"names a record that does\nnot exist yet, by coordinates\" -- wants the ADR conversation improvise-hkbc's guard\nis designed to force. DO NOT SLIP IT IN AS A VARIANT.\n\nCHEAPEST HONEST INTERIM: leave add-record-row declined and land the coordinate-rename\nticket first, which answers most of the same question and may settle the naming mode\non a case where the record's identity is at least partly known.\n\nTHE CLASS THIS BELONGS TO, worth writing into ADR 002 or context/code/protocol.md\nbecause it will be rediscovered otherwise: a client cannot name a record it is about\nto create. That is ADR 002 working as designed, and it is INVISIBLE from the\nModelCommand list, because every existing variant either names things by name or\nnames a record that already exists. `will_be_created_by_the_frame` (ui/reduce.rs:284)\nexempts such a record from the basis check, which reads as though the naming problem\nwere solved. It is not, and the two are easy to confuse.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:53Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-1ohv","depends_on_id":"improvise-fdk1","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fo0w","title":"Three black-hole keys are resolvable today over vocabulary that already exists","description":"Found by the improvise-h3y7 completeness audit. THE HEADLINE OF THAT AUDIT is that the\nvocabulary is far more complete than assumed: of thirteen gaps examined, EIGHT are\nclosed in the vocabulary and open only in the resolver. These three need NO new wire\nsurface at all.\n\nEach sits on the_command_names_that_still_need_the_host_are_these (resolve.rs:2114)\nand is therefore a BLACK HOLE -- gotcha 47: Host::handle answers ClientFrame::Key with\nan explicit empty vec, so these keys do nothing at all in a browser rather than being\nmerely slower.\n\n1. `search` -- EnterSearchMode::execute (cmd/mode.rs:386) emits exactly\n [SetSearchMode(true), SetSearchQuery(\"\")]. Both are Commands. Composable in one\n line; the cheapest single win in the audit.\n\n BUT IT MUST NOT SHIP ALONE. search-navigate scans the model for matching cells and\n the client has only a viewport-windowed GridCache, so it is NOT composable --\n shipping `/` without `n` gives a browser user a search box that finds nothing,\n which is a WORSE failure than the current silence. Either land both (search-navigate\n needs a results projection or a host request frame -- file separately once scoped)\n or decline `/` out loud in the meantime.\n\n2. `enter-advance` -- EnterAdvance::execute (cmd/navigation.rs:144) is pure cursor\n arithmetic returning viewport_effects(...), i.e. SetSelected/SetRowOffset/\n SetColOffset, all on the wire. Its row_count/col_count are GridCache::extent, which\n improvise-bpq2 added for precisely this. The bottom-right AbortChain becomes\n KeyOutcome::Ignored. NOTE improvise-bd9o measures this as the black hole that\n swallows a records-mode commit.\n\n3. `hide-selected-row-item` -- ModelCommand::HideItem { view, category, item } exists.\n The operands are AxisFrame::cats.first() and the selected row's label tuple [0],\n both on GridCache.row_frame (gotcha 44).\n\nRULES THAT APPLY TO ALL THREE:\n- Each arm mirrors its Cmd LINE FOR LINE, including where siblings disagree\n (doctrine(mirror_the_cmd_line_for_line), gotcha 47). Tidying them into one rule is\n what makes the client a second derivation.\n- Each must come OFF the host list in the same commit. That list is where closing a\n gap has to come and say so.\n- tests/lockstep.rs is where the evidence goes.\n\nNOT IN THIS TICKET: force-quit, which is in the same file and is a product question\n(what does Ctrl-Q mean in a browser tab?). It should stop being an upstream black hole\neither way, but which way is a ruling.\n\nCAVEAT FROM THE AUDIT, stated so nobody treats the sizes as measured: the auditor read\neach Cmd::execute and confirmed every effect it emits has a Command, but did not write\nthe arms. Operand availability in GridCache is read off the types, not exercised.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T00:23:57Z","started_at":"2026-09-24T00:23:57Z","lease_expires_at":"2026-09-24T00:28:57Z","heartbeat_at":"2026-09-24T00:23:57Z","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s","title":"ADR 004: an Emacs-like face system, so a theme is UI-agnostic data the front ends apply","description":"USER DIRECTION 2026-09-22: \"I think we want to iterate towards having a way to\ntheme the app like emacs does: faces, etc. described in a UI-agnostic format that\nthe different frontends can apply (there's probably a swift version down the road\nand maybe Qt, etc.)\"\n\nNote \"iterate towards\". This is a DIRECTION, not a feature request, and the first\ndeliverable is an ADR (004 is free) rather than code.\n\nWHERE THE REPO ACTUALLY IS TODAY, measured:\n - AxisColor { Row, Column, Page, Filter, Inactive } in improvise-core/view/axis.rs\n -- names ROLES. This is the pattern working.\n - MinibufColor { Green, Cyan, Yellow } in improvise-protocol/lib.rs -- names\n COLOURS. draw.rs::minibuf_fg is an IDENTITY MAP (Green -\u003e Color::Green). Its\n doc comment says \"semantic palette only\" and the type makes that false: there\n is nothing to theme. The roles exist at the call sites (green is editing,\n yellow is the prompt/destructive family, cyan is search) and were spelled as\n colours.\n - 124 `Color::` literals across 10 TUI files (draw.rs, grid.rs, every panel,\n help, the wizard). No theme, palette or color_scheme concept anywhere.\n - PR #120 just built a CSS custom-property palette for the browser. So the two\n front ends are AT A FORK: either colour roles become shared vocabulary each\n end maps to its own palette, or the web palette and the TUI's 124 literals\n drift permanently and one document looks unrelated in two clients.\n\nSEVEN QUESTIONS THE ADR MUST ANSWER. They are listed because each is a place this\ngets decided by accident otherwise.\n\n1. THE TERMINAL-PALETTE TENSION, WHICH IS THE HARD ONE. A face's colour may want\n to be \"the terminal's green\" rather than an RGB value. Today Color::Green means\n whatever the USER'S TERMINAL calls green, so their terminal theme applies. A\n format carrying only RGB would LOSE that -- a regression, not a gap, and one\n that would be discovered late and be expensive to undo. Emacs answers it with\n named colours plus tty approximation. Decide whether a face attribute can be\n a NAMED terminal colour, an RGB value, or either, and what a browser does with\n \"terminal green\".\n\n2. INHERITANCE is what makes a face system tractable rather than 124 entries.\n Emacs derives most faces from a few. Decide whether faces inherit, and if so\n whether the resolution happens in shared code (once, correctly) or per front\n end (three times, differently).\n\n3. A THEME IS CLIENT-LOCAL. Gotcha 29 / the user's own ruling: the model is shared\n and the UI is not. So a theme must NOT be a Command, must NOT live in\n ViewState, and must NOT be projected -- otherwise one client's theme reaches\n another client's screen, which is the exact bug improvise-g0f is about. State\n this explicitly in the ADR; it is cheap now and a protocol change later.\n\n4. WHERE THE VOCABULARY LIVES. AxisColor is in improvise-core, which is supposed\n to have no UI; MinibufColor is in improvise-protocol. That inconsistency\n predates this and the ADR should resolve it rather than inherit it.\n\n5. WHICH ATTRIBUTES ARE PORTABLE. Foreground, background, weight, slant,\n underline map onto ratatui Modifier, CSS, Swift and Qt. Box, shadow and\n rounded corners do not reach a terminal. Decide the minimum common set, and\n decide what a front end does with an attribute it cannot render -- Emacs\n ignores it, which is probably right and should be SAID.\n\n6. THE FORMAT. A Swift or Qt front end means the theme is DATA, not Rust -- so it\n needs a file format. TOML, JSON, or a small pest grammar in this repo's usual\n style. It is NOT part of .improv: a theme is configuration, not document data,\n and putting it there would make a document dictate a reader's colours.\n\n7. HOW IT ARRIVES WITHOUT A FLAG DAY. A big-bang refactor of 124 literals is how\n this dies. The ADR needs a migration path where the vocabulary lands first and\n the literals move opportunistically.\n\nTHE INCREMENTAL STEPS THAT FORECLOSE NOTHING, cheapest first:\n a. Rename MinibufColor's variants to roles. Pure vocabulary, no behaviour, and\n it converts \"semantic palette\" from a false doc comment into a true type.\n b. Name PR #120's CSS custom properties after the same roles, so the web\n palette is already a theme in the making rather than a second vocabulary.\n c. Let the 124 literals migrate as files are touched for other reasons.\n\nNOT IN SCOPE YET: a theme picker, user-supplied theme files, or live reloading.\nThose follow the vocabulary; none of them is the hard part.","status":"in_progress","priority":2,"issue_type":"task","assignee":"agent-faces","owner":"el-github@elangley.org","created_at":"2026-09-22T05:44:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:30Z","started_at":"2026-09-25T04:45:30Z","lease_expires_at":"2026-09-25T04:50:30Z","heartbeat_at":"2026-09-25T04:45:30Z","dependencies":[{"issue_id":"improvise-nn4s","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-oujb","title":"A release should carry the web app too: today a tag captures only the native binaries","description":"USER RULING 2026-09-22, asked whether the release workflow builds a package for the\nweb version and told it does not: \"Attach the site to releases too.\"\n\nWHAT IS TRUE TODAY. .github/workflows/release.yml has ZERO references to wasm, web,\npkg, pages or assemble -- verified by grep. It builds four native targets\n(aarch64/x86_64 x macOS/Linux) plus a shell installer, per dist-workspace.toml.\nThe web app ships by a completely separate route: ci.yml's pages-deploy, gated on\n`github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`. Continuous\ndeployment from the default branch, not a release artifact.\n\nFOUR CONSEQUENCES, none of them written down anywhere:\n - A TAG DOES NOT DEPLOY THE SITE. pages-deploy fires only on a push to main, so\n tagging leaves the site wherever main last was. Tag from main's tip and they\n coincide BY ACCIDENT; tag an older commit and they diverge silently.\n - There is no web version of a release to download, self-host or roll back to.\n - THE PAGE NAMES NO VERSION AT ALL. web/assemble.sh stamps the cache-bust token\n with $GITHUB_SHA, but nothing in web/index.html shows a version or commit, so\n a visitor cannot tell what they are running and neither can a bug report.\n - A binary user and a site user are running different code, potentially months\n apart: v0.1.0-rc2's binaries are from 2026-04-11 and the site is from whatever\n merged most recently. README's Installation section does not say so.\n\nTHE ASK: a tagged release also carries the assembled site as an artifact, so a\nrelease has a self-hostable web bundle and a rollback target.\n\nFIVE TRAPS, each of which is how this gets done wrong:\n\n1. release.yml IS AUTOGENERATED BY cargo-dist and says so on line 1. A hand edit\n is clobbered by the next `dist init`. Use dist's own supported mechanism for\n extra artifacts (see its `extra-artifacts` / custom build configuration in\n dist-workspace.toml) so the addition SURVIVES REGENERATION. If no supported\n mechanism fits, say so explicitly and argue for the hand edit rather than\n making it silently -- and leave a comment in dist-workspace.toml so the next\n `dist init` has a warning.\n2. DO NOT WRITE A SECOND WAY TO BUILD THE BUNDLE. ci.yml's `build` job carries a\n long comment explaining that everything downstream DOWNLOADS from one\n wasm-pack run precisely so \"the harness runs against the tree Pages publishes\"\n is true by construction. A release job that builds its own bundle a different\n way reintroduces exactly what that comment prevents. Use web/build.sh and\n web/assemble.sh, the same scripts a developer runs.\n3. THE CACHE-BUST STAMP TAKES AN ARGUMENT AND THE RIGHT ONE DIFFERS. CI passes\n $GITHUB_SHA; a local run defaults to `dev`. A release should almost certainly\n pass the TAG. Decide, and note scripts/assemble-stamp.sh verifies each stamped\n reference independently -- check it still passes with whatever you choose.\n4. THE RELEASE JOB NEEDS A TOOLCHAIN IT DOES NOT HAVE: wasm32-unknown-unknown,\n a PINNED wasm-pack (ci.yml pins v0.15.0 and explains that pinning is what makes\n a local measurement evidence about a CI run), and node for the prose site.\n Reuse those pins rather than picking fresh ones.\n5. wasm-opt REJECTS LLVM's memory.copy WITHOUT --enable-bulk-memory, and the\n worker bundle ALSO needs --enable-nontrapping-float-to-int because it carries\n the whole App and so chrono's date arithmetic (gotcha 45). Those live in the\n crates' Cargo.toml metadata; do not re-specify them.\n\nALSO IN SCOPE, because it is the cheap half of the same problem: GIVE THE PAGE A\nVISIBLE VERSION OR COMMIT. A site that cannot say what it is makes every bug\nreport about it unanswerable. Small, and worth more than the tarball.\n\nDECIDE AND STATE, do not default: whether tagging should ALSO deploy Pages, or\nwhether the site keeps tracking main and a release merely captures a copy. The\nuser chose \"attach the site to releases\", which does not by itself answer this.\nBoth are defensible; supporting both by accident is not.\n\nNOT IN SCOPE: bumping a version or cutting a tag (improvise-3tj / improvise-l36,\nboth need the user), and RELEASING.md, which improvise-gx5d owns on another branch.","notes":"USER RULING 2026-09-22, settling the open decision this ticket flagged: \"I like\nPages showing the latest main for now.\"\n\nSO: pages-deploy is UNCHANGED. It keeps its `github.event_name == 'push' \u0026\u0026\ngithub.ref == 'refs/heads/main'` gate; a tag does not deploy the site. A release\nmerely CAPTURES a copy of the assembled site as an artifact.\n\nNote the words \"for now\". This is a current preference, not a permanent\narchitecture decision, and whoever reads it later should be able to tell the\ndifference. Do not record it as settled forever.\n\nTHREE THINGS THAT FOLLOW, and they change the emphasis of the work:\n\n1. THE TWO LINEAGES DIVERGING IS NOW DELIBERATE, so it gets DOCUMENTED rather than\n fixed. A binary user runs whatever was tagged; a site visitor runs latest main.\n One sentence in README's Installation section.\n\n2. THE VERSION STAMP IS THE MOST VALUABLE PART OF THIS TICKET, not the tarball.\n With the lineages deliberately different, a page that cannot say what it is\n makes every bug report about it unanswerable. web/index.html names no version\n at all today. If only one thing lands from this bead, it is the stamp.\n\n3. THE ARTIFACT'S PURPOSE IS NARROWER THAN IT SOUNDS. With Pages tracking main,\n the release tarball is NOT what anyone visits -- it is a self-hostable copy and\n a rollback target. Saying \"the web version of v0.1.0\" invites a reader to\n assume the tarball is what is deployed, which is false and costs an afternoon.\n State the narrow claim.\n\nThe cache-bust token question narrows too: since pages-deploy is untouched, the\nrelease capture is the only new caller of web/assemble.sh, so the tag is the\nobvious token. Check scripts/assemble-stamp.sh still passes with it.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:31:13Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:31:44Z","dependencies":[{"issue_id":"improvise-oujb","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gx5d","title":"The tag-push release workflow: diagnosis, a preflight guard, and the missing procedure","description":"USER 2026-09-22: \"we need to fix the tag push release workflow\" ... then, on being\nshown the diagnosis: \"do nothing for now, just make sure the workflow will work\"\nand yes to a preflight guard.\n\nTHE WORKFLOW WAS NEVER BROKEN, and the evidence is stronger than a local check.\nv0.1.0-rc1 and v0.1.0-rc2 (2026-04-11) BOTH have full published GitHub Releases:\ntarballs for all four targets, their .sha256 files, dist-manifest.json,\nsource.tar.gz and improvise-installer.sh. So the workflow has run end to end and\nproduced everything it is supposed to produce.\n\nWHAT ACTUALLY HAPPENED: v0.1.0-rc3 was pushed to gh, origin and gitea while every\ncrate still said 0.1.0-rc2. dist matches the tag against the ROOT PACKAGE version\n(dist-workspace.toml says members = [\"cargo:.\"], so only the root package counts\nand gp-tree sitting at 0.1.0 is not a finding). It refused with \"This workspace\ndoesn't have anything for dist to Release!\" and named the right answer itself, and\nthe four jobs behind `plan` skipped.\n\nReproduced both directions locally: `dist plan --tag=v0.1.0-rc2` gets past the\ncheck; `--tag=v0.1.0-rc3` gives the CI error verbatim.\n\nA CORRECTION WORTH KEEPING, because it nearly became a false fact in context/: I\nfirst concluded rc1/rc2 had never triggered the workflow, inferring it from an\nempty `gh run list`. GitHub Actions logs AGE OUT AT 90 DAYS and those tags are\nfive months old. An absent run and a discarded run look identical from the run\nlist, which is the same shape as doctrine(a_missing_loose_ref_is_not_a_deleted_branch).\nCheck `gh release view \u003ctag\u003e --json assets` -- the artifacts outlive the logs.\n\nTHE REAL ROOT CAUSE IS DOCUMENTATION. There is no release procedure anywhere:\n - .github/workflows/release.yml is cargo-dist's autogenerated file -- a\n mechanism, not a procedure.\n - README section Installation describes the current state and is accurate.\n - context/plan.md Phase 3 has one-time SETUP steps from the launch plan,\n including \"test the workflow by tagging v0.1.0-rc1 ... Delete the rc tag\n afterward\" -- which was not done; all three rc tags are still on all three\n remotes.\nNothing anywhere says BUMP THE VERSION BEFORE YOU TAG.\n\nIN FLIGHT on branch fix/release-preflight: scripts/release-preflight.sh (refuses a\ndirty tree, compares tag to root version, runs `dist plan` as a second opinion and\nsays when it could not), its check_the_checker, a CI step in the `test` job, the\nreasoning in context/code/tooling.md, and a short RELEASING procedure.\n\nNOTE THE TEST LESSON, which is the transferable part: the preflight's first test\nasserted only \"did it refuse\", and every negative case still passed with the tag\ncomparison replaced by `if false;` -- because the script refuses a DIRTY WORKING\nTREE first and this repo's tree is almost always dirty from bd's auto-export. Four\nof five checks were answering \"is the tree dirty\" while claiming to answer \"does\nit catch a bad tag\". It now runs against a clean throwaway checkout AND asserts the\nrefusal names the version mismatch.\n\nNO VERSION IS BUMPED AND NO TAG IS CUT. Cutting v0.1.0 is improvise-3tj and\npublishing is improvise-l36; both need the user.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:26:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:26:50Z","dependencies":[{"issue_id":"improvise-gx5d","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-owep","title":"Path traversal: a connected client can make the server read any file it can read (LoadModel)","description":"USER RULING 2026-09-22: \"We'll rely on sufficient containerization, but we should\nprevent path traversal attacks mainly: the server should have a documents\ndirectory and make sure the path canonicalizes to that directory.\"\n\nTHIS IS LIVE TODAY, NOT A FUTURE CONCERN. Verified by the orchestrator while\nscoping the ruling:\n\n - ModelCommand::LoadModel(PathBuf) is ON THE WIRE (protocol/src/reduce.rs:159).\n - effect::LoadModel (command/src/ui/effect.rs:914) calls\n persistence::load_from(\u0026*app.storage, \u0026self.0) with the CLIENT'S path.\n - PhysicalFs (io/src/storage.rs:94) has NO root and NO confinement:\n File::open(path) and File::create(path) on whatever arrives.\n - improvise-ws-server builds its App with App::new(workbook, path), which\n supplies that unconfined PhysicalFs.\n\nSo ANY CONNECTED WEBSOCKET CLIENT CAN ASK THE HOST TO READ ANY FILE THE SERVER\nPROCESS CAN READ. A .improv-shaped file loads wholesale and is then projected\nback to the client. A file that does NOT parse is still a leak risk: the error\ngoes into view_state.status_msg as \"Load error: {e}\", and pest errors quote the\noffending input.\n\nWRITE IS NOT YET CLIENT-REACHABLE -- Save/SaveAs are ViewOnly and off the wire --\nBUT IT IS ABOUT TO BE. improvise-y11g (the : command line, a core feature per the\nuser) reaches `:w \u003cpath\u003e`, and improvise-2mqz adds the export/download frames.\nLanding either without this is how arbitrary file WRITE ships.\n\nPAIR WITH improvise-kc7n: the Origin check answers what PAGE is driving a client,\nnever WHO is connecting, and improvise-kpnr (authentication) is open. Confinement\nis the control that does not depend on either.\n\nWHERE IT BELONGS: THE BACKEND, NOT THE CALL SITES. Gotcha 2 -- every document read\nor write goes through App::storage, and the host picks the backend. So this is a\nROOTED PhysicalFs (e.g. PhysicalFs::rooted(dir)), and every caller is covered by\nconstruction rather than by each remembering to check. That is\ndoctrine(an_invariant_is_made_unrepresentable_not_validated), the same answer\nprune_axis_items and gp-tree's Row\u003cT\u003e got.\n\nCRITICALLY, THIS IS A PROPERTY OF THE SERVER, NOT OF Storage. A TUI user may open\nany file they own; confining App::new would be WRONG. The server constructs the\nrooted backend; the TUI keeps the unrooted one. Gotcha 2 already says the host\npicks, so this fits without a trait change.\n\nSIX IMPLEMENTATION TRAPS, each of which is the standard way this fix is got wrong:\n\n1. std::fs::canonicalize REQUIRES THE PATH TO EXIST. A write to a NEW document\n fails it. Canonicalize the PARENT and join the final component -- and reject a\n final component that is itself `..` or contains a separator.\n2. REJECT, DO NOT SANITIZE. Stripping or collapsing `..` is the classic broken\n fix; `....//` and percent/unicode variants defeat it. Canonicalize, then\n compare.\n3. COMPARE COMPONENT-WISE, NOT AS STRINGS. `/srv/docs-evil`.starts_with(\"/srv/docs\")\n is TRUE as a string and false as a path. Use Path::starts_with, which is\n component-wise, and canonicalize the ROOT once at construction (a relative or\n symlinked root defeats the comparison otherwise).\n4. SYMLINKS: canonicalize resolves them, so a link inside the directory pointing\n out IS caught -- but only because the check runs on the resolved path. Do not\n \"optimise\" it into a lexical normalisation.\n5. TOCTOU IS A STATED RESIDUAL, NOT A SOLVED PROBLEM. Between the check and the\n open, a component can be replaced. openat2(RESOLVE_BENEATH) is the real answer\n and portable Rust does not have it. The user has ruled that containerization\n carries this; SAY SO in the code's context/ prose rather than implying the\n check is total.\n6. ABSOLUTE PATHS INSIDE THE ROOT must be allowed or the feature is unusable, and\n absolute paths outside it refused -- so the rule is \"canonicalizes under the\n root\", never \"is relative\".\n\nWHAT THE SERVER'S CLI GAINS: a --documents-dir (name it as you like), replacing or\nconstraining the current `files: Vec\u003cPathBuf\u003e` list. Decide and state whether the\nexisting multi-file form survives as \"these files, which must be under the root\"\nor is replaced by \"this directory\". Both are defensible; silently supporting both\nis not.\n\nTESTS -- and the check_the_checker discipline applies, because a confinement test\nthat passes against an unconfined backend is worse than none:\n - ../ escapes, absolute escapes, a symlink inside the root pointing out, a root\n that is itself a symlink, and the `/srv/docs-evil` string-prefix case.\n - A NEW file inside the root must be WRITABLE -- the case trap 1 breaks.\n - Drive it through ModelCommand::LoadModel over Sessions, not just against\n PhysicalFs directly: the vulnerability is reachable from the wire and the test\n should be too.\n - INJECT THE FAULT: point the rooted backend's root at `/` and watch the escape\n tests fail. A confinement test that cannot fail is the characteristic defect\n of this repo.\n - Consider whether io/src/storage.rs::contract::storage_contract should gain a\n confinement clause. Note OpfsFs is confined by the browser already and\n MemoryFs is a map, so this may be a PhysicalFs property rather than a contract\n one -- decide explicitly and write down which.","notes":"DEFERRED BY USER RULING 2026-09-22: \"The server isn't running anywhere yet so this\ncan be deferred.\"\n\nThat is an EXPOSURE judgement, not a correctness one. Everything in the analysis\nabove stands: the path really is client-supplied, PhysicalFs really is unrooted,\nand the read really would succeed. What is absent is a deployment -- nobody is\nrunning improvise-ws-server, so there is no process for a client to reach and no\nfilesystem to read. Priority dropped P0 -\u003e P2 and the bead set BLOCKED so it stays\noff `bd ready`, NOT closed.\n\nTHE DEFERRAL IS CONDITIONAL, AND THE CONDITION IS ALREADY WRITTEN DOWN AS A\nDEPENDENCY. improvise-y11g (the : command line) and improvise-2mqz (SaveAs /\nExportCsv) are both `blocked-by` this ticket, and that edge is now MORE load-\nbearing rather than less: it is what stops the exposure arriving before the fix.\nDo not remove those edges to unblock either ticket. If one of them becomes urgent,\nthis one comes with it.\n\nTHREE THINGS THAT REOPEN IT, so the condition is testable rather than remembered:\n 1. improvise-ws-server being deployed or demoed anywhere reachable, including a\n tunnel for a Show HN.\n 2. improvise-y11g or improvise-2mqz landing, either of which makes a\n client-supplied path reach a WRITE.\n 3. Any new ModelCommand or frame that carries a path.\n\nWHAT DOES NOT REOPEN IT: the browser worker deployment. Its Storage is OpfsFs,\nconfined by the browser to the origin's private filesystem, and the document is\nthe user's own. This ticket is about improvise-ws-server only.\n\nNOTE FOR WHOEVER PICKS IT UP: the analysis above was written against a live\nreading of the tree at d17ad0f. Re-verify rather than trust it -- a deferred\nsecurity finding whose premises have quietly changed is worse than an open one,\nbecause the priority says it was judged and the judgement may no longer hold.","status":"blocked","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:24:28Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:25:03Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-owep","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-0w7y","title":"Browser UI polish: dismissable banner, a minibuffer a web user will see, and more contrast","description":"USER, 2026-09-22, looking at the deployed page at fiddlerwoaroof.github.io/improvise/app/:\n\n 1. \"I just noticed the minibuffer is at the bottom of the screen, that's a bit\n surprising and not really 'in your face' for a web page.\"\n 2. \"we need to do a frontend design polish for the UI. The general aesthetics\n are pretty good, but it should 'pop' a bit\"\n 3. \"we should make the giant 'Everything runs here in this tab ...' thing\n dismissable\"\n\nWHAT THE SCREENSHOT SHOWS. The banner is ~10 dense lines of prose ABOVE THE FOLD,\npushing the tile bar, category tree, formula panel and grid down. It explains\n?doc=, ?seed=, ?ws=, wss:// vs ws:// and reverse proxies -- reference material,\non first paint, every visit. The minibuffer was showing \"Display a menu\" in small\ntext at the very bottom-left.\n\nSCOPE IS PRESENTATION ONLY. RenderMinibuffer is built in wasm-client/src/render.rs\nfrom state.view.mode.minibuffer(), and both front ends read the same semantic\nvalue. Moving or restyling it must NOT add a second derivation of what it says\n(design-principles section 5; improvise-edp is the worked example). The bottom\nline stays right for the TUI -- this is about the web page.\n\nNOT THIS TICKET: making the minibuffer REACHABLE. That is improvise-6acs (verify\nthe \"typing blind\" report) and improvise-y11g (the : command line). Seeing it and\nreaching it are different bugs and the tracker should not let one stand in for\nthe other.\n\nTHE CONSTRAINT THAT SHAPES THE WORK: harness/tests/shell.spec.js is the only thing\nthat executes web/*.js, and it CANNOT see CSS or the grouped/multi-level DOM\n(gotcha 48). So a dismissed banner surviving a reload is testable and most of the\npolish is not -- which means the untestable part must be ARGUED, and the split\nbetween the two must be stated rather than blurred.\n\nBanner dismissal is per-viewer browser storage, which is what localStorage is\nlegitimately for: wrap every read and write in try/catch and render correctly when\nit throws or returns nothing.\n\nIN FLIGHT on branch ui/shell-polish.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:27Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:21:27Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-0w7y","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6acs","title":"Verify the browser minibuffer report: painting is tested, reaching the mode is not","description":"USER REPORT 2026-09-21: \"the 'minibuffer' for things like typing in a new category\nname, etc. doesn't actually show up anywhere so you're typing blind.\"\n\nTHIS WAS NEVER FILED. It is filed now as a VERIFY-FIRST ticket, because the obvious\ndiagnosis is wrong and the real cause may already be fixed.\n\nWHY IT IS NOT A PAINTING BUG. web/app.js (:247-251) paints the minibuffer, and\nrender.rs::every_minibuffer_mode_paints_what_is_being_typed asserts all four\ntext-entry modes paint their prompt and the typed value. That test exists because\npainting WAS the bug once (improvise-fdt6, evidence in\ncontext/test-evidence/clients.md) and it was fixed.\n\nWHY THE TEST CANNOT SEE THE REPORTED SYMPTOM, which is the interesting part: it\nfolds Command::ChangeMode(mode) DIRECTLY. It never presses a key. So it proves the\npaint works GIVEN the mode, and says nothing about whether a keypress can REACH the\nmode. That is this repo's characteristic defect -- a test that passes while the\nthing it names is broken -- sitting in the exact place the symptom points.\n\nSO THE HYPOTHESIS TO TEST IS REACHABILITY, not rendering: does a browser keypress\nactually put the client into AppMode::CategoryAdd? resolve.rs::stranding() refuses\nonly SavePrompt, ExportPrompt and CommandMode, so category-add is not stranded --\nbut `search-or-category-add` sat on the_command_names_that_still_need_the_host_are_these\nuntil PR #114 moved it off (improvise-koh6). The report predates that merge.\n\nSO IT MAY ALREADY BE FIXED. Verify against current main FIRST and close on evidence\nif so -- do not build anything before reproducing.\n\nIF IT REPRODUCES: the fix is whatever makes the key reach the mode, and the test\nthat earns its place is one that goes from a KEYPRESS to a painted minibuffer, not\nfrom a folded ChangeMode. Add it beside the existing one rather than replacing it;\nthey answer different questions and both are worth having.\n\nIF IT DOES NOT REPRODUCE: say so on the ticket with the keypress you used, and\nstrengthen the existing test anyway -- a keypress-to-paint test is the guard that\nwould have caught this class, and its absence is why a user found it instead.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:14:30Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:14:30Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-6acs","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:35Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1ohv","title":"add-record-row cannot be named on the wire: a client cannot create a record","description":"Found by the improvise-h3y7 completeness audit.\n\nAddRecordRow (cmd/grid.rs:492) builds a CellKey from the active view's\nfiltering_cats() page selections (gotcha 27: Page union Filter, NOT\ncategories_on(Axis::Page)) and writes SetCell(key, Number(0.0)).\n\nA client cannot compose it, for the same reason the coordinate rename cannot be\ncomposed: ModelCommand::SetCell takes a RecordCell whose RecordHash is an opaque\nsurrogate the server resolves against cells that ALREADY EXIST (App::resolve_record,\nui/app.rs:516). The record this command creates does not exist, so there is nothing\nto name it with.\n\nThe client DOES have the operands -- GridCache::page_coords carries the page\nselections (gotcha 33), and page_coords is framed by nothing and survives a reshape.\nWhat it lacks is a variant that TAKES them.\n\nWHY THIS IS NOT A ONE-LINE ADDITION. A ModelCommand that names coordinates rather\nthan a surrogate is a NEW NAMING MODE in the vocabulary. ADR 002 contemplated two:\n\"names its target by name\" (a category, an item, a view) and \"names a record that\nalready exists by a position-free surrogate\". A third -- \"names a record that does\nnot exist yet, by coordinates\" -- wants the ADR conversation improvise-hkbc's guard\nis designed to force. DO NOT SLIP IT IN AS A VARIANT.\n\nCHEAPEST HONEST INTERIM: leave add-record-row declined and land the coordinate-rename\nticket first, which answers most of the same question and may settle the naming mode\non a case where the record's identity is at least partly known.\n\nTHE CLASS THIS BELONGS TO, worth writing into ADR 002 or context/code/protocol.md\nbecause it will be rediscovered otherwise: a client cannot name a record it is about\nto create. That is ADR 002 working as designed, and it is INVISIBLE from the\nModelCommand list, because every existing variant either names things by name or\nnames a record that already exists. `will_be_created_by_the_frame` (ui/reduce.rs:284)\nexempts such a record from the basis check, which reads as though the naming problem\nwere solved. It is not, and the two are easy to confuse.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:53Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-1ohv","depends_on_id":"improvise-fdk1","type":"blocks","created_at":"2026-09-22T04:48:02Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ohv","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:04:59Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fo0w","title":"Three black-hole keys are resolvable today over vocabulary that already exists","description":"Found by the improvise-h3y7 completeness audit. THE HEADLINE OF THAT AUDIT is that the\nvocabulary is far more complete than assumed: of thirteen gaps examined, EIGHT are\nclosed in the vocabulary and open only in the resolver. These three need NO new wire\nsurface at all.\n\nEach sits on the_command_names_that_still_need_the_host_are_these (resolve.rs:2114)\nand is therefore a BLACK HOLE -- gotcha 47: Host::handle answers ClientFrame::Key with\nan explicit empty vec, so these keys do nothing at all in a browser rather than being\nmerely slower.\n\n1. `search` -- EnterSearchMode::execute (cmd/mode.rs:386) emits exactly\n [SetSearchMode(true), SetSearchQuery(\"\")]. Both are Commands. Composable in one\n line; the cheapest single win in the audit.\n\n BUT IT MUST NOT SHIP ALONE. search-navigate scans the model for matching cells and\n the client has only a viewport-windowed GridCache, so it is NOT composable --\n shipping `/` without `n` gives a browser user a search box that finds nothing,\n which is a WORSE failure than the current silence. Either land both (search-navigate\n needs a results projection or a host request frame -- file separately once scoped)\n or decline `/` out loud in the meantime.\n\n2. `enter-advance` -- EnterAdvance::execute (cmd/navigation.rs:144) is pure cursor\n arithmetic returning viewport_effects(...), i.e. SetSelected/SetRowOffset/\n SetColOffset, all on the wire. Its row_count/col_count are GridCache::extent, which\n improvise-bpq2 added for precisely this. The bottom-right AbortChain becomes\n KeyOutcome::Ignored. NOTE improvise-bd9o measures this as the black hole that\n swallows a records-mode commit.\n\n3. `hide-selected-row-item` -- ModelCommand::HideItem { view, category, item } exists.\n The operands are AxisFrame::cats.first() and the selected row's label tuple [0],\n both on GridCache.row_frame (gotcha 44).\n\nRULES THAT APPLY TO ALL THREE:\n- Each arm mirrors its Cmd LINE FOR LINE, including where siblings disagree\n (doctrine(mirror_the_cmd_line_for_line), gotcha 47). Tidying them into one rule is\n what makes the client a second derivation.\n- Each must come OFF the host list in the same commit. That list is where closing a\n gap has to come and say so.\n- tests/lockstep.rs is where the evidence goes.\n\nNOT IN THIS TICKET: force-quit, which is in the same file and is a product question\n(what does Ctrl-Q mean in a browser tab?). It should stop being an upstream black hole\neither way, but which way is a ruling.\n\nCAVEAT FROM THE AUDIT, stated so nobody treats the sizes as measured: the auditor read\neach Cmd::execute and confirmed every effect it emits has a Command, but did not write\nthe arms. Operand availability in GridCache is read off the types, not exercised.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:16Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:11Z","started_at":"2026-09-24T00:23:57Z","closed_at":"2026-09-24T21:45:11Z","close_reason":"Landed in main via #275 (reviewed; merged 2026-09-24).","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cfly","title":"The wave ships view history with no key that reaches it: declined() still refuses view-back","description":"Found by the wave's integration agent at preview 30560e0f, and it is a WAVE-LEVEL\nresidue rather than a defect in any branch -- which is why nobody could see it.\n\nPR #115 makes the view history genuinely work for a connected client:\nViewBack/ViewForward join the view half of Command, reduce_view folds all three\nview effects line for line, each client's own view_back_stack is maintained, and\na deleted view is pruned from every client's history via Command::ForgetView.\n\nBUT resolve.rs's composed() has NO view-back or view-forward arm, and declined()\nstill answers \"stepping back to the previous view is not available in the browser\nyet\" -- byte-identical to base.\n\nSo the merged wave contains the CAPABILITY and not the KEY. A browser user\npresses `\u003c` and is told the feature does not exist, over machinery that now\nfully supports it.\n\nWHY NEITHER AGENT COULD SEE IT: #115 was told not to touch resolve.rs (another\nagent owned it) and correctly reported the exact lines instead. #114 owned\nresolve.rs and had no reason to look at declined() at all. Reading #115 alone you\nwould conclude browser view-history works; reading #114 alone you would never\nlook.\n\nTHE EXACT LINES, from #115's report and confirmed by the integration agent as\nstill byte-identical to base:\n\n crates/improvise-protocol/src/resolve.rs\n 239: // NOT \"not built\": it commits a drill first, so composing it would\n 240: // commit another client's typing. Needs addressed commands -- clients.md.\n 241: \"view-back\" =\u003e \"stepping back to the previous view\",\n\nDELETE THE COMMENT WITH THE ARM. Its stated reason is WRONG as of #115: composing\nview-back does not commit another client's typing, because a browser session has\nno drill state to commit. Leaving a false reason behind is worse than leaving the\ndecline.\n\nWHAT TO ADD: a composed() arm returning vec![Command::ViewBack], and the forward\nsibling. Note gotcha 47's rule -- each resolver mirrors its Cmd LINE FOR LINE,\nincluding where siblings disagree -- so check ViewNavigate's own execute() rather\nthan assuming the two directions are symmetric.\n\nPRECONDITION: #115 merged. Nothing else; #115 named this explicitly.\n\nNOT IN SCOPE: line 234, \"drill-into-cell\" =\u003e \"drill\". That one still needs the\n_Drill view session-local and the staging design, and the user has since ruled\nthat browser staging must be identical to the TUI's. See improvise-gd28.\n\nALSO: make sure the wave's PR body does not claim browser view-history works.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:17:26Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:22:26Z","closed_at":"2026-09-22T05:22:26Z","close_reason":"Closed","labels":["browser","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-en4s","title":"No guard that a client's formula panel loses a formula its target item's deletion removed","description":"Found while reviewing improvise-g3no; the reasoning is in\ncontext/code/command.md beside the other Dirty notes.\n\nDirty::of(ModelChange::CellData) reports formulas: true. That was written as\ndeliberate over-reporting in the cheap direction -- a cell write cannot change\na formula, so the entry cost one redundant Projection and bought nothing.\n\nIt is now load-bearing. improvise-g3no made Model::remove_item drop the\nformulas targeting the item, and effect::RemoveItem is CellData, so a delete\nreally can take a formula out of the document. Tightening that arm would leave\nevery connected client's formula panel showing a formula the document no longer\nhas, forever, with nothing in the stream to correct it -- gotcha 35's expensive\ndirection, and exactly the failure that put `formulas` on the LayoutGeometry arm\nin the first place.\n\nNothing guards it. removing_a_category_empties_the_clients_formula_panel covers\nthe LayoutGeometry twin only. The missing test is its item-level sibling: a\nsession whose formula panel names a _Measure formula target, a\nModelCommand::RemoveItem for that target, and an assertion that the client's\nFormulaCache no longer names it. Inject the fault by setting formulas: false on\nthe CellData arm of Dirty::of.\n\nLives in ui/project.rs, which is why it is filed rather than written.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-cmd2","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:48Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:43:14Z","started_at":"2026-09-23T02:29:32Z","closed_at":"2026-09-23T02:43:14Z","close_reason":"PR #157. Dirty::of(CellData) now reports formulas: true -- RemoveItem is CellData and Model::remove_item drops the formulas targeting the item, so a client's formula panel kept showing a formula the document no longer had. WHAT THE TICKET DID NOT PREDICT, and it changed the test: written the obvious way (connect, AddFormula, RemoveItem) the test fails under the rival AT THE SETUP ASSERTION, not the property -- because AddFormula is ITSELF CellData, so with the arm tightened the formula never reaches the client at all. Red for the neighbouring question where the property is about a deletion. The formula is therefore seeded BEFORE Sessions::connect, so the client holds it from the bootstrap seed, which no Dirty gates. TWO CORRECTIONS TO THE TICKET'S PREMISE: 'nothing guards it' read greener than it was -- the rival also takes down two other tests, but at setup and incidentally, which is what made it look both unguarded and covered depending which way you ran it; and the LayoutGeometry sibling is fine and was left alone, because only the CellData twin shares an arm with its own setup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xolb","title":"The wave brief's gate list omitted the node-test jobs, the only gate that sees an unpainted RenderModel field","description":"Found by the wave's integration agent diffing my wave brief against .github/workflows/ci.yml.\n\nMY GATE LIST OMITTED A REAL CI JOB:\n\n ./scripts/node-test.sh 'web/tests/*.test.js' ci.yml:239 \"Browser JS unit tests (node --test)\"\n web/tests/bundle/*.test.js ci.yml:371 (needs a built bundle)\n\nAt base: 36 tests over 5 files, 0 fail. Neither my brief nor context/repo-map.md's \"Testing -- the short version\" mentions either.\n\nWHY IT MATTERS, and it is not a formality: web/tests/render_surface.test.js is the MECHANICAL guard at the RenderModel -\u003e DOM boundary. It parses the pub fields off crates/improvise-wasm-client/src/render.rs and requires model.\u003cfield\u003e to appear in web/app.js + web/shell.js.\n\nPROVEN BY INJECTION: adding `pub injected_unpainted_field: u64` to RenderModel fails that gate with actual: ['injected_unpainted_field'], expected: []. cargo test --workspace is COMPLETELY INDIFFERENT to it.\n\nSo an agent working from a gate list that omits it can expose a projected field, never paint it, pass every gate it was told to run, and ship the defect. That is precisely the cross-branch shape a wave produces: one agent adds the field, another owns the painting.\n\nTWO BLIND SPOTS THE GUARD STATES ABOUT ITSELF, confirmed by reading, so it should not be over-trusted either:\n - it is a SUBSTRING search (page.includes(\"model.\" + field)), so a field read only inside a function nothing calls reads as painted\n - its field regex requires exactly four-space indentation and a lowercase name\n\nTHE GENERALISABLE PART: a gate list carried in a brief is a COPY of CI, and a copy drifts. The brief is written from memory and the repo-map, and both were stale here. Checking the brief against ci.yml is a once-per-wave action, not a once-per-agent one -- no single agent would find this, because q and s never touch RenderModel and r and t each individually pass cargo test.\n\nWHAT TO DO: add both node jobs to the repo-map's testing section and to the wave brief template, so the next brief inherits CI rather than memory.\n","notes":"WIDENED 2026-09-22 by the improvise-rw6l agent, which hit the gap from the other\nside and lost a CI cycle to it.\n\nTHE GATE LIST OMITS A SECOND NODE SUITE, not just one. web/tests/bundle/ does NOT\nmatch the glob web/tests/*.test.js, so an agent given the standard gate list never\nruns it. wasm_surface.test.js is a ratchet on \"the handful of calls the page\nmakes\" and needs a built web/pkg, so it also has a PRECONDITION the other suite\ndoes not. It ran only in CI and failed there (commit 0a920f1).\n\nSo the standard brief needs BOTH lines, and the second needs its precondition\nstated:\n\n ./scripts/node-test.sh 'web/tests/*.test.js'\n web/build.sh \u0026\u0026 ./scripts/node-test.sh 'web/tests/bundle/*.test.js'\n\nTWO MORE ENVIRONMENT NOTES FROM THE SAME AGENT, both of which cost time and\nneither of which is written anywhere:\n\n 1. harness/node_modules DOES NOT EXIST IN A FRESH WORKTREE, and `npx playwright\n test` then fails with \"Cannot find package '@playwright/test'\" -- an error\n about the package, not about the harness, so it reads as a broken checkout.\n `npm install` in harness/ first. (AGENTS.md says npm ci, never npm install,\n for the repo's own dependency discipline; reconcile the two before writing\n this into a brief.)\n 2. The shell guard refuses a heredoc'd Python script for reasons not\n reproducible from its content: the same `python3 - \u003c\u003c'PY'` shape ran\n repeatedly and was then refused twice on a block containing only Rust test\n text. `python3 /dev/stdin \u003c\u003c'PYEOF'` worked. Worth a line in AGENTS.md's\n environment notes beside the `gh` and `bd` ones.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:15Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:29:42Z","started_at":"2026-09-23T05:01:12Z","closed_at":"2026-09-23T06:29:42Z","close_reason":"Fixed in PR #201, merged after adversarial review (31b5acd, then the delta at 141be9f). Follow-ups: improvise-28ri, improvise-oga4, improvise-bf0q.","labels":["docs","testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-piyp","title":"every_bound_command_name_is_registered is blind to prefix sub-bindings and F-keys","description":"PROVEN BY INJECTION 2026-09-21 by the wave's integration agent.\n\nevery_bound_command_name_is_registered (improvise-command/src/command/keymap.rs:417) has two blind spots:\n\n - the walk does `Some(Binding::Prefix(_)) | None =\u003e {}` at line 458 -- it NEVER descends into a prefix sub-keymap\n - its private probe_keys() (line 491) covers 0x20..0x7f plus 14 named keys but NO F-keys, while the neighbouring public probe_keys_pub() (line 905) DOES include F(1)/F(2)\n\nINJECTION: two command names bound that no registry entry provides -- one under the `g` prefix, one on F(5) in Normal.\n - every_bound_command_name_is_registered -\u003e ok\n - cargo test --workspace -\u003e 1595 passed, 0 failed, 7 ignored, 37 binaries, BYTE-IDENTICAL to baseline\n\nPOSITIVE CONTROL, which is what separates \"untested path\" from \"broken test\": the same bogus name on Char('~'), a probed non-prefix key -\u003e the test FAILS. So the guard works; its reach is the defect.\n\nCONSEQUENCE: a command name bound only under g/y/Z, or on a function key, can be unregistered -- OR have args that will not parse, since improvise-s13 moved enter-mode into the args-carrying class -- and the whole suite stays green. That is the exact failure the guard was written for, surviving in the region it does not reach.\n\ndump_every_binding DOES descend into prefixes via which_key, but it is a DUMP gated on IMPROVISE_BINDING_DUMP, not an assertion -- inert in CI, so it does not cover the gap.\n\nTHE CONTRAST THAT MAKES THIS WORTH FIXING RATHER THAN NOTING: nothing_declined_is_also_composed in improvise-protocol/src/resolve.rs is the STRONGER guard -- its bound_commands helper DOES recurse into Binding::Prefix and into parents. So the resolution side is guarded where the registration side is not, and an agent reading either test alone would reasonably conclude its bindings are checked. Only comparing the two shows which half is real.\n\nFIX SHAPE: descend into Binding::Prefix in the walk, and make probe_keys cover what probe_keys_pub already does. Both are small. The acceptance is the injection above failing -- test it with a name behind a prefix AND one on an F-key, because they are two different blind spots and one fix may not close both.\n\nRelevant: gotcha 13 (prefixes are stored as an address; KeymapSet::prefix_keymap walks parents so RecordsNormal reaches Normal's g/y/Z) and gotcha 15 (the guard checks BOTH halves of dispatch -- the name resolves and the args parse).\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:49:14Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:16Z","closed_at":"2026-09-22T02:27:16Z","close_reason":"Fixed in PR #112 (wave/q-keymap, head b4bd6bc), both blind spots closed and each injection fails SEPARATELY -- which was the acceptance, because a fix for one need not close the other.\n\n - PREFIX: reachable_names now recurses into Binding::Prefix. Reported via RecordsNormal, which confirms the INHERITED-prefix path rather than only a locally-bound one.\n - F-KEY: probe_keys now DELEGATES to probe_keys_pub, which covers F(1)..=F(12).\n\nTHE FINDING THE BEAD DID NOT PREDICT, and it is the better half: the F-key gap was not an oversight about F-keys. probe_keys and probe_keys_pub were DUPLICATES differing by two lines, and the gap WAS the drift between them. Adding F-keys to both would have closed the symptom and left the mechanism intact. So the duplication doctrine applies to TEST SCAFFOLDING, not only to production derivations -- two copies of a test's probe key space is the same disease as two keymap spines, and it failed the same way.","labels":["command","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-b8ot","title":"Delegate DOM events: one handler per surface on the stable root, not one per node","description":"USER RULING 2026-09-21: \"I want one handler outside the area that gets redrawn\nand dispatch events there.\"\n\nTODAY: one listener PER NODE, created inside the render loops and reallocated on\nevery repaint. Only `window` keydown (app.js:509) and the transport listeners\nare delegated.\n\n app.js:361 td mousedown ONE PER GRID CELL, inside the cell loop\n app.js:436 li click one per panel row\n app.js:476 span click one per tile\n app.js:493 li click one per category row\n app.js:414 input keydown the editor\n app.js:424 input blur the editor\n\nEight replaceChildren sites rebuild the DOM wholesale, so each closure is\ndiscarded and reallocated per frame, and repaints happen roughly per keystroke.\n\nTHE ROOTS ARE ALREADY STABLE, which is what makes this cheap. replaceChildren\nreplaces an element's CHILDREN, not the element, so every `el.*` registry entry\n(app.js:30-43) survives every repaint:\n\n el.grid \u003c- replaceChildren at :214 delegate td mousedown here\n el.categories \u003c- :218 delegate li click here\n el.tiles \u003c- :479 delegate span click here\n el.formulas / el.views \u003c- via `host` at :439 delegate li click here\n\nSo each surface already has exactly the persistent ancestor the ruling asks for.\nNo new wrapper element is needed. Read indices off data- attributes\n(data-row/data-col on a cell, data-index on a row or tile) with\nevent.target.closest(..).\n\nFOUR THINGS A NAIVE REWRITE BREAKS. All are documented and load-bearing:\n\n1. THE GRID USES mousedown, NOT click, DELIBERATELY (improvise-0x9u). From an\n editing state the input's blur repaints first, so the \u003ctd\u003e is gone before a\n click could fire. Delegation does not change that -- the target is detached\n before the event -- so it must stay mousedown.\n\n2. IT CALLS preventDefault(). Dropping that reintroduces focus/selection\n behaviour the editor depends on.\n\n3. IT CONDITIONALLY SKIPS THE REPAINT. On an editable cell it calls edit(..) and\n does NOT repaint, because a repaint would take the \u003cinput\u003e away. The comment\n at :356-360 says so. A delegated handler must keep that branch.\n\n4. IT CLOSES A LIVE EDITOR FIRST (`if (editing) editing.close(false)`), before\n composing the click. Order matters.\n\nTHE EDITOR'S OWN TWO LISTENERS (keydown, blur at :414/:424) are a different\ncase: one transient node, one at a time, attached at creation and gone with it.\nDelegating those buys nothing and would need the blur/keydown to find the input\nthrough a root that does not own it. Leave them direct unless there is a reason;\nsay so in the change rather than silently keeping them.\n\nWHY THIS IS NOT PRIMARILY A PERFORMANCE TICKET, and should not be argued as one:\nthe cell count is bounded by the VIEWPORT, not the document -- set_viewport\nannounces a real window and GridCache is viewport-indexed for the browser\n(gotcha 47) -- so it is a few hundred closures per repaint, not thousands. And\nit does not leak: replaceChildren drops the nodes and their listeners with them.\nimprovise-i5tk's rule applies -- do not claim a performance win without a\nbenchmark, and \"it is fine\" is exactly as unevidenced as \"this dominates\".\n\nThe real argument is the repo's own: design-principles section 5 wants web/app.js to\ntrend to \"paint and wire\", and one handler per surface is wire where one handler\nper node is bookkeeping that grows with the data.\n\nTESTING: a change to web/*.js belongs in harness/tests/shell.spec.js (gotcha 48),\nnot a new ad-hoc surface. The assertions that matter are the four above, each of\nwhich is a behaviour a rewrite can silently lose: a click from an OPEN EDITOR\nstill lands (that is 0x9u's own case), the editor is not taken away when opening\none, and a click on the panel/tile surfaces still dispatches after a repaint.\nPure decision logic (target -\u003e indices) can go in web/shell.js, which is\nnode --test-able with no DOM.\n","notes":"USER LATITUDE 2026-09-21: \"We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.\"\n\nThe mechanism is open. One observation that should shape which is used, because the plumbing already exists and a delegated handler that ignores it would be a second derivation.\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js gridPlan (lines 88-108) builds each cell with row, col, text, editText, editable, and a classes map of numeric/error/selected/typing/editable. web/app.js line 352 does nothing but toggle those classes onto the node. So row, col and editable are ALREADY decided upstream and already reach the node -- and row deliberately counts DATA rows rather than trusting position, because a group header is not a data row (shell.js lines 72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather than re-derive it from DOM structure. Writing data-row / data-col / an editable class is carrying an existing answer. Walking up the table counting rows to work out which data row was clicked would be a SECOND derivation of the very thing shell.js 72-74 exists to get right, and it would silently disagree the moment a group header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure, it has no DOM, and it is already node --test-able -- so \"which action does this node mean\" can be unit-tested without a browser, leaving the delegated handler as a lookup. That is design-principles section 5's paint-and-wire applied to the dispatch as well as to the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist. editable is already a class; reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are. Not for rows or cells -- an id must be unique per document and these are rebuilt every frame.\n\nA node's action is then: surface from the delegation root, operands from data attributes, branch from the class. No structural inference anywhere.\nUSER LATITUDE 2026-09-21: 'We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.'\n\nSo the mechanism is open. One observation that should shape which is used, because\nthe plumbing already exists and a delegated handler that IGNORES it would be a\nsecond derivation:\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js::gridPlan (:88-108)\nbuilds each cell as:\n\n { row: dataRow, col, text, editText, editable,\n classes: { numeric, error, selected, typing, editable } }\n\nand web/app.js:352 does nothing but over that map.\nSo , and are ALREADY decided upstream and already reach the\nnode -- deliberately counts DATA rows rather than trusting position,\nbecause a group header is not a data row (shell.js:72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather\nthan re-derive it from DOM structure. Writing data-row / data-col / an \nclass is carrying an existing answer; walking up the table counting \u003ctr\u003es to work\nout which data row was clicked would be a SECOND derivation of the very thing\nshell.js:72-74 exists to get right, and it would silently disagree the moment a\ngroup header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure,\nit has no DOM, and it is already node --test-able -- so 'which action does this\nnode mean' can be unit-tested without a browser, leaving the delegated handler as\na lookup. That is design-principles section 5's 'paint and wire' applied to the\ndispatch as well as the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are\n values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist ( is already a class).\n Reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are (#grid,\n #categories, #tiles). Not for rows or cells -- an id must be unique per\n document and these are rebuilt per frame.\n\nA cell's action is then (surface from the root, operands from data-, branch from\nthe class), with no structural inference anywhere.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:14:47Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-636d","title":"Introduce a global keymap that almost all modes inherit from","description":"User ask, 2026-09-21: confirm keymap inheritance works and the keymaps are\nactually shared, and put `?` in a base keymap overridden only in insert mode.\n\nVERIFIED at 523d571. Three findings, and the first is good news.\n\n## 1. THE KEYMAPS ARE GENUINELY SHARED. No drift is possible.\n\n browser: resolve.rs:53 Keymaps::shared().lookup(..)\n TUI: improvise-command/src/command/keymap.rs:127 Keymaps::default_keymaps()\n\nDifferent constructors, but `shared()` (keymap.rs:446) is a OnceLock over\n`default_keymaps` itself:\n\n static KEYMAPS: OnceLock\u003cKeymaps\u003e = OnceLock::new();\n KEYMAPS.get_or_init(Keymaps::default_keymaps)\n\nSo there is ONE definition and two callers -- a cached copy and a fresh one of\nthe same function. Content is identical by construction, not by a test holding\ntwo copies in step. This is the shape design-principles section 5 asks for and it\nis already right.\n\n## 2. INHERITANCE WORKS AND IS USED TWICE IN SEVENTEEN MODES.\n\nThe mechanism is real -- Keymap::with_parent, and lookup() walks parents. But\n`with_parent` is called at exactly two sites:\n\n keymap.rs:656 RecordsNormal inherits Normal\n keymap.rs:834 RecordsEditing inherits Editing (via TextEntry::with_parent)\n\nEvery other mode is a ROOT keymap. PanelKeymap::new and TextEntry::new both\nbuild with Keymap::new(), so the three panel modes, TileSelect, SearchMode,\nHelp, ImportWizard and the seven other text-entry modes inherit from nothing.\n\nThere is no base keymap. What looks like sharing across modes is actually\nSPINES -- PanelKeymap::new and TextEntry::new each re-bind a common set into a\nfresh map. That is duplication-by-builder rather than inheritance: correct\ntoday, and nothing makes it stay correct.\n\n## 3. `?` RESOLVES IN 2 OF 17 MODES.\n\nIt is bound once, keymap.rs:551, on `normal`:\n\n normal.bind_args(KeyCode::Char('?'), none, \"enter-mode\", vec![\"help\"])\n\nSo it works in Normal and -- via the one inheritance edge -- RecordsNormal.\nIt does NOT resolve in: CategoryPanel, FormulaPanel, ViewPanel, TileSelect,\nSearchMode, ImportWizard, or Help itself. Those are exactly the modes a user is\nmost likely to be lost in.\n\nIt correctly does not resolve in the eight text-entry modes, where `?` must type\na literal `?`.\n\n## THE PROPOSED FIX IS FREE, and this is the load-bearing detail\n\nPut `?` in a base keymap and let every non-insert mode inherit it. **Text-entry\nmodes need NO explicit override**, because of the existing lookup order:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent\n\n`lookup_local` is exhausted BEFORE the parent is consulted (keymap.rs:261-264).\nSo a text-entry child's own `AnyChar` -\u003e append-char shadows a parent's exact\n`?` automatically. The override the user asked for already falls out of the\nfallback order; it does not have to be written.\n\n## WHAT TO DECIDE WHILE DOING IT\n\n - Which keys belong in the base besides `?`. `:` (command mode) is already\n re-bound by both PanelKeymap::new and normal, and is a candidate. Do not\n sweep everything in: a base keymap that binds too much makes every mode's\n keys harder to reason about, which is the opposite of the ask.\n - Whether `?` in Help CLOSES the overlay. Today Help has no `?` at all. Toggling\n is the obvious reading and is not what the current binding does anywhere.\n - Whether the panel spines should BECOME inheritance rather than re-binding.\n That is the larger change and can follow; it is what would stop the\n duplication from drifting.\n\n## GUARD TO ADD\n\nThere is no test that a key resolves in every mode that should have it.\n`every_hint_key_resolves_in_its_mode` checks each mode's HINT against its own\nmap, which cannot see a key that no hint mentions. `prefix_keymaps_are_flat`\nand `every_bound_command_name_is_registered` are the other two and neither\ncovers this. So the acceptance is a test naming the base keys and asserting they\nresolve in every non-text-entry ModeKey -- and, in the other direction, that\nthey resolve to append-char in the text-entry ones.\n\nRelated: improvise-uln5 records that the help OVERLAY advertises eight keys that\ndo nothing in the browser. `?` being unreachable from most modes is the other\nhalf of the same complaint -- the help is hard to reach and then wrong.\n","notes":"USER RULING 2026-09-21: 'Almost all the command modes should inherit from a global mode.'\n\nThat supersedes this bead's original framing. It is no longer 'put ? in a base\nkeymap' -- ? is one symptom. The work is the global keymap itself, and the panel\nand text-entry SPINES become inheritance rather than re-binding.\n\nWHAT IS DUPLICATED TODAY, which is the concrete scope:\n\n bind_panel_toggles(\u0026mut km) is called TWICE -- keymap.rs:367 (PanelKeymap::new)\n and :555 (normal). It binds one toggle-panel-and-focus per Panel::ALL. So the\n panel toggles are already written once and APPLIED twice into two roots; that\n is the duplication-by-builder this ruling removes.\n\n ':' -\u003e enter-mode command bound in PanelKeymap::new (:369) and again in normal.\n '?' -\u003e enter-mode help bound ONLY in normal (:551). The asymmetry between\n these two is itself evidence nothing is keeping the spines in agreement.\n\nWHY THIS IS SAFE FOR 'ALMOST ALL' RATHER THAN NEEDING EXCEPTIONS -- the existing\nfallback order does the work:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent (keymap.rs:261-264)\n\nLocal is exhausted BEFORE the parent. Two consequences that make the ruling\ncheap:\n - a TEXT-ENTRY mode inheriting the global still types a literal '?', because its\n own AnyChar -\u003e append-char is found locally first. No override needed.\n - IMPORT WIZARD binds KeyPattern::Any, which catches everything locally, so it\n inherits harmlessly -- nothing ever reaches the parent.\n\nSo the modes that must NOT inherit are those where a global key would be actively\nwrong AND is not already shadowed. Name them explicitly rather than assuming;\nthe honest answer may be 'none', which would make it 'all' rather than 'almost\nall' and is worth saying out loud if so.\n\nWHAT STILL NEEDS DECIDING, and these are the product calls:\n - Which keys are global. '?' and ':' and the panel toggles are the candidates\n the duplication already nominates. Resist sweeping more in: a global that\n binds too much makes every mode harder to reason about, which is the opposite\n of the ask.\n - Whether '?' in Help CLOSES the overlay. Today Help binds no '?' at all.\n - Esc is NOT a candidate: it means something different in nearly every mode.\n\nGUARDS THE CHANGE OWES. There is no test that a key resolves in every mode that\nshould have it -- every_hint_key_resolves_in_its_mode checks a mode's hint\nagainst its own map and cannot see a key no hint mentions. So:\n - every global key resolves in every mode that inherits the global\n - in the eight text-entry modes it resolves to append-char instead\n - bind_panel_toggles has ONE call site afterwards, which is the mechanical\n check that the duplication is actually gone\n\nNote gotcha 13: prefix keys are stored as an ADDRESS and resolved via\nKeymaps::prefix_keymap, which walks parents -- so RecordsNormal already reaches\nNormal's g/y/Z that way. prefix_keymaps_are_flat must still pass; a global\nkeymap must not introduce a nested prefix.\n\nThe good news from the original investigation stands and is not affected: the\nkeymaps are genuinely SHARED between the two front ends. Keymaps::shared() is a\nOnceLock over default_keymaps itself, so there is one definition and two callers,\nnot two copies held in step by a test.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:33:38Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:33:55Z","labels":["protocol","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uln5","title":"The help overlay advertises 8 black-hole keys and a stranded command page, and no guard sees it","description":"MEASURED under Chromium at 523d571 from a fresh page, one key at a time, with\nthree controls (C, P, T) that DID move -- so \"nothing happened\" is a measurement\nand not a blind probe:\n\n NOTHING / search\n NOTHING n search-navigate\n NOTHING N search-or-category-add\n NOTHING H hide-selected-row-item\n NOTHING z toggle-group-under-cursor\n NOTHING Enter enter-advance\n NOTHING Control+s save\n NOTHING Control+q force-quit\n MOVED C / P / T (controls)\n\nEight of the eleven names in the_command_names_that_still_need_the_host_are_these\nare advertised in the help overlay the BROWSER RENDERS. Help page 5 additionally\nadvertises the entire ':' command line (:add-cat, :w, :q, :import, :export...),\nand CommandMode is stranding()'d, so that whole page is unreachable too.\n\nWHY THE EXISTING GUARD CANNOT SEE THIS. every_advertised_command_answers_in_its_own_mode\nreads help::advertised, which is the HINT-LINE table only. search-or-category-add\nis correctly absent from AppMode::Normal's hint -- the guard is right about what\nit checks. The help OVERLAY (help.rs::pages()) is a SECOND, INDEPENDENT body of\nadvertising in the same file, and nothing cross-references it against resolve.\n\nclients.md's own severity rule -- \"a key named in the mode's own hint line doing\nnothing reads as BROKEN\" -- applies with full force to the overlay and was never\nextended to it. Two bodies of advertising, one guarded.\n\nTHE STRUCTURAL OBSTACLE, which is the real cost and is why this is not trivial:\nhelp.rs::keys(mode, command, args, description) knows the command name AT\nCONSTRUCTION and then THROWS IT AWAY -- HelpLine::Keys { keys: Vec\u003cString\u003e,\ndescription } keeps only rendered key labels. So the analogous guard cannot be\nwritten without first carrying command/mode/args onto the variant. Small, but\nnot zero, and it has to happen before any check can exist.\n\nNote improvise-wk18 is the sibling already filed for help.rs's literal() lines,\nwhich the same guard cannot see for a related reason (a key string with no\ncommand name). Whoever takes either should read both -- they are one change to\nHelpLine's shape.\n\nSee also improvise-koh6 (P1): this is not merely cosmetic. Following the overlay's\nadvertised N corrupts a data cell, because the keystrokes that follow a dead key\nland on live NORMAL bindings and `i` opens an editor.\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":"in_progress","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-24T00:07:20Z","started_at":"2026-09-24T00:07:20Z","lease_expires_at":"2026-09-24T00:12:20Z","heartbeat_at":"2026-09-24T00:07:20Z","labels":["browser","testing","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b8ot","title":"Delegate DOM events: one handler per surface on the stable root, not one per node","description":"USER RULING 2026-09-21: \"I want one handler outside the area that gets redrawn\nand dispatch events there.\"\n\nTODAY: one listener PER NODE, created inside the render loops and reallocated on\nevery repaint. Only `window` keydown (app.js:509) and the transport listeners\nare delegated.\n\n app.js:361 td mousedown ONE PER GRID CELL, inside the cell loop\n app.js:436 li click one per panel row\n app.js:476 span click one per tile\n app.js:493 li click one per category row\n app.js:414 input keydown the editor\n app.js:424 input blur the editor\n\nEight replaceChildren sites rebuild the DOM wholesale, so each closure is\ndiscarded and reallocated per frame, and repaints happen roughly per keystroke.\n\nTHE ROOTS ARE ALREADY STABLE, which is what makes this cheap. replaceChildren\nreplaces an element's CHILDREN, not the element, so every `el.*` registry entry\n(app.js:30-43) survives every repaint:\n\n el.grid \u003c- replaceChildren at :214 delegate td mousedown here\n el.categories \u003c- :218 delegate li click here\n el.tiles \u003c- :479 delegate span click here\n el.formulas / el.views \u003c- via `host` at :439 delegate li click here\n\nSo each surface already has exactly the persistent ancestor the ruling asks for.\nNo new wrapper element is needed. Read indices off data- attributes\n(data-row/data-col on a cell, data-index on a row or tile) with\nevent.target.closest(..).\n\nFOUR THINGS A NAIVE REWRITE BREAKS. All are documented and load-bearing:\n\n1. THE GRID USES mousedown, NOT click, DELIBERATELY (improvise-0x9u). From an\n editing state the input's blur repaints first, so the \u003ctd\u003e is gone before a\n click could fire. Delegation does not change that -- the target is detached\n before the event -- so it must stay mousedown.\n\n2. IT CALLS preventDefault(). Dropping that reintroduces focus/selection\n behaviour the editor depends on.\n\n3. IT CONDITIONALLY SKIPS THE REPAINT. On an editable cell it calls edit(..) and\n does NOT repaint, because a repaint would take the \u003cinput\u003e away. The comment\n at :356-360 says so. A delegated handler must keep that branch.\n\n4. IT CLOSES A LIVE EDITOR FIRST (`if (editing) editing.close(false)`), before\n composing the click. Order matters.\n\nTHE EDITOR'S OWN TWO LISTENERS (keydown, blur at :414/:424) are a different\ncase: one transient node, one at a time, attached at creation and gone with it.\nDelegating those buys nothing and would need the blur/keydown to find the input\nthrough a root that does not own it. Leave them direct unless there is a reason;\nsay so in the change rather than silently keeping them.\n\nWHY THIS IS NOT PRIMARILY A PERFORMANCE TICKET, and should not be argued as one:\nthe cell count is bounded by the VIEWPORT, not the document -- set_viewport\nannounces a real window and GridCache is viewport-indexed for the browser\n(gotcha 47) -- so it is a few hundred closures per repaint, not thousands. And\nit does not leak: replaceChildren drops the nodes and their listeners with them.\nimprovise-i5tk's rule applies -- do not claim a performance win without a\nbenchmark, and \"it is fine\" is exactly as unevidenced as \"this dominates\".\n\nThe real argument is the repo's own: design-principles section 5 wants web/app.js to\ntrend to \"paint and wire\", and one handler per surface is wire where one handler\nper node is bookkeeping that grows with the data.\n\nTESTING: a change to web/*.js belongs in harness/tests/shell.spec.js (gotcha 48),\nnot a new ad-hoc surface. The assertions that matter are the four above, each of\nwhich is a behaviour a rewrite can silently lose: a click from an OPEN EDITOR\nstill lands (that is 0x9u's own case), the editor is not taken away when opening\none, and a click on the panel/tile surfaces still dispatches after a repaint.\nPure decision logic (target -\u003e indices) can go in web/shell.js, which is\nnode --test-able with no DOM.\n","notes":"USER LATITUDE 2026-09-21: \"We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.\"\n\nThe mechanism is open. One observation that should shape which is used, because the plumbing already exists and a delegated handler that ignores it would be a second derivation.\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js gridPlan (lines 88-108) builds each cell with row, col, text, editText, editable, and a classes map of numeric/error/selected/typing/editable. web/app.js line 352 does nothing but toggle those classes onto the node. So row, col and editable are ALREADY decided upstream and already reach the node -- and row deliberately counts DATA rows rather than trusting position, because a group header is not a data row (shell.js lines 72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather than re-derive it from DOM structure. Writing data-row / data-col / an editable class is carrying an existing answer. Walking up the table counting rows to work out which data row was clicked would be a SECOND derivation of the very thing shell.js 72-74 exists to get right, and it would silently disagree the moment a group header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure, it has no DOM, and it is already node --test-able -- so \"which action does this node mean\" can be unit-tested without a browser, leaving the delegated handler as a lookup. That is design-principles section 5's paint-and-wire applied to the dispatch as well as to the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist. editable is already a class; reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are. Not for rows or cells -- an id must be unique per document and these are rebuilt every frame.\n\nA node's action is then: surface from the delegation root, operands from data attributes, branch from the class. No structural inference anywhere.\nUSER LATITUDE 2026-09-21: 'We can use IDs, classes or data attributes as necessary to disambiguate the action to be taken.'\n\nSo the mechanism is open. One observation that should shape which is used, because\nthe plumbing already exists and a delegated handler that IGNORES it would be a\nsecond derivation:\n\nTHE CELL ALREADY CARRIES ITS OWN IDENTITY ACROSS. web/shell.js::gridPlan (:88-108)\nbuilds each cell as:\n\n { row: dataRow, col, text, editText, editable,\n classes: { numeric, error, selected, typing, editable } }\n\nand web/app.js:352 does nothing but over that map.\nSo , and are ALREADY decided upstream and already reach the\nnode -- deliberately counts DATA rows rather than trusting position,\nbecause a group header is not a data row (shell.js:72-74).\n\nCONSEQUENCE: a delegated handler should read what gridPlan already decided rather\nthan re-derive it from DOM structure. Writing data-row / data-col / an \nclass is carrying an existing answer; walking up the table counting \u003ctr\u003es to work\nout which data row was clicked would be a SECOND derivation of the very thing\nshell.js:72-74 exists to get right, and it would silently disagree the moment a\ngroup header appears.\n\nWHERE THE DECISION BELONGS: gridPlan, and the panel/tile equivalents. It is pure,\nit has no DOM, and it is already node --test-able -- so 'which action does this\nnode mean' can be unit-tested without a browser, leaving the delegated handler as\na lookup. That is design-principles section 5's 'paint and wire' applied to the\ndispatch as well as the paint.\n\nPICKING BETWEEN THE THREE, given the latitude:\n - data attributes for the OPERANDS (data-row, data-col, data-index). They are\n values, and an attribute is where a value goes.\n - classes for the PREDICATES that already exist ( is already a class).\n Reusing it costs nothing and keeps one answer.\n - IDs only for the surfaces themselves, which they already are (#grid,\n #categories, #tiles). Not for rows or cells -- an id must be unique per\n document and these are rebuilt per frame.\n\nA cell's action is then (surface from the root, operands from data-, branch from\nthe class), with no structural inference anywhere.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:36:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T03:14:47Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-b8ot","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-636d","title":"Introduce a global keymap that almost all modes inherit from","description":"User ask, 2026-09-21: confirm keymap inheritance works and the keymaps are\nactually shared, and put `?` in a base keymap overridden only in insert mode.\n\nVERIFIED at 523d571. Three findings, and the first is good news.\n\n## 1. THE KEYMAPS ARE GENUINELY SHARED. No drift is possible.\n\n browser: resolve.rs:53 Keymaps::shared().lookup(..)\n TUI: improvise-command/src/command/keymap.rs:127 Keymaps::default_keymaps()\n\nDifferent constructors, but `shared()` (keymap.rs:446) is a OnceLock over\n`default_keymaps` itself:\n\n static KEYMAPS: OnceLock\u003cKeymaps\u003e = OnceLock::new();\n KEYMAPS.get_or_init(Keymaps::default_keymaps)\n\nSo there is ONE definition and two callers -- a cached copy and a fresh one of\nthe same function. Content is identical by construction, not by a test holding\ntwo copies in step. This is the shape design-principles section 5 asks for and it\nis already right.\n\n## 2. INHERITANCE WORKS AND IS USED TWICE IN SEVENTEEN MODES.\n\nThe mechanism is real -- Keymap::with_parent, and lookup() walks parents. But\n`with_parent` is called at exactly two sites:\n\n keymap.rs:656 RecordsNormal inherits Normal\n keymap.rs:834 RecordsEditing inherits Editing (via TextEntry::with_parent)\n\nEvery other mode is a ROOT keymap. PanelKeymap::new and TextEntry::new both\nbuild with Keymap::new(), so the three panel modes, TileSelect, SearchMode,\nHelp, ImportWizard and the seven other text-entry modes inherit from nothing.\n\nThere is no base keymap. What looks like sharing across modes is actually\nSPINES -- PanelKeymap::new and TextEntry::new each re-bind a common set into a\nfresh map. That is duplication-by-builder rather than inheritance: correct\ntoday, and nothing makes it stay correct.\n\n## 3. `?` RESOLVES IN 2 OF 17 MODES.\n\nIt is bound once, keymap.rs:551, on `normal`:\n\n normal.bind_args(KeyCode::Char('?'), none, \"enter-mode\", vec![\"help\"])\n\nSo it works in Normal and -- via the one inheritance edge -- RecordsNormal.\nIt does NOT resolve in: CategoryPanel, FormulaPanel, ViewPanel, TileSelect,\nSearchMode, ImportWizard, or Help itself. Those are exactly the modes a user is\nmost likely to be lost in.\n\nIt correctly does not resolve in the eight text-entry modes, where `?` must type\na literal `?`.\n\n## THE PROPOSED FIX IS FREE, and this is the load-bearing detail\n\nPut `?` in a base keymap and let every non-insert mode inherit it. **Text-entry\nmodes need NO explicit override**, because of the existing lookup order:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent\n\n`lookup_local` is exhausted BEFORE the parent is consulted (keymap.rs:261-264).\nSo a text-entry child's own `AnyChar` -\u003e append-char shadows a parent's exact\n`?` automatically. The override the user asked for already falls out of the\nfallback order; it does not have to be written.\n\n## WHAT TO DECIDE WHILE DOING IT\n\n - Which keys belong in the base besides `?`. `:` (command mode) is already\n re-bound by both PanelKeymap::new and normal, and is a candidate. Do not\n sweep everything in: a base keymap that binds too much makes every mode's\n keys harder to reason about, which is the opposite of the ask.\n - Whether `?` in Help CLOSES the overlay. Today Help has no `?` at all. Toggling\n is the obvious reading and is not what the current binding does anywhere.\n - Whether the panel spines should BECOME inheritance rather than re-binding.\n That is the larger change and can follow; it is what would stop the\n duplication from drifting.\n\n## GUARD TO ADD\n\nThere is no test that a key resolves in every mode that should have it.\n`every_hint_key_resolves_in_its_mode` checks each mode's HINT against its own\nmap, which cannot see a key that no hint mentions. `prefix_keymaps_are_flat`\nand `every_bound_command_name_is_registered` are the other two and neither\ncovers this. So the acceptance is a test naming the base keys and asserting they\nresolve in every non-text-entry ModeKey -- and, in the other direction, that\nthey resolve to append-char in the text-entry ones.\n\nRelated: improvise-uln5 records that the help OVERLAY advertises eight keys that\ndo nothing in the browser. `?` being unreachable from most modes is the other\nhalf of the same complaint -- the help is hard to reach and then wrong.\n","notes":"USER RULING 2026-09-21: 'Almost all the command modes should inherit from a global mode.'\n\nThat supersedes this bead's original framing. It is no longer 'put ? in a base\nkeymap' -- ? is one symptom. The work is the global keymap itself, and the panel\nand text-entry SPINES become inheritance rather than re-binding.\n\nWHAT IS DUPLICATED TODAY, which is the concrete scope:\n\n bind_panel_toggles(\u0026mut km) is called TWICE -- keymap.rs:367 (PanelKeymap::new)\n and :555 (normal). It binds one toggle-panel-and-focus per Panel::ALL. So the\n panel toggles are already written once and APPLIED twice into two roots; that\n is the duplication-by-builder this ruling removes.\n\n ':' -\u003e enter-mode command bound in PanelKeymap::new (:369) and again in normal.\n '?' -\u003e enter-mode help bound ONLY in normal (:551). The asymmetry between\n these two is itself evidence nothing is keeping the spines in agreement.\n\nWHY THIS IS SAFE FOR 'ALMOST ALL' RATHER THAN NEEDING EXCEPTIONS -- the existing\nfallback order does the work:\n\n lookup_local: exact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any\n lookup: lookup_local(..) THEN parent (keymap.rs:261-264)\n\nLocal is exhausted BEFORE the parent. Two consequences that make the ruling\ncheap:\n - a TEXT-ENTRY mode inheriting the global still types a literal '?', because its\n own AnyChar -\u003e append-char is found locally first. No override needed.\n - IMPORT WIZARD binds KeyPattern::Any, which catches everything locally, so it\n inherits harmlessly -- nothing ever reaches the parent.\n\nSo the modes that must NOT inherit are those where a global key would be actively\nwrong AND is not already shadowed. Name them explicitly rather than assuming;\nthe honest answer may be 'none', which would make it 'all' rather than 'almost\nall' and is worth saying out loud if so.\n\nWHAT STILL NEEDS DECIDING, and these are the product calls:\n - Which keys are global. '?' and ':' and the panel toggles are the candidates\n the duplication already nominates. Resist sweeping more in: a global that\n binds too much makes every mode harder to reason about, which is the opposite\n of the ask.\n - Whether '?' in Help CLOSES the overlay. Today Help binds no '?' at all.\n - Esc is NOT a candidate: it means something different in nearly every mode.\n\nGUARDS THE CHANGE OWES. There is no test that a key resolves in every mode that\nshould have it -- every_hint_key_resolves_in_its_mode checks a mode's hint\nagainst its own map and cannot see a key no hint mentions. So:\n - every global key resolves in every mode that inherits the global\n - in the eight text-entry modes it resolves to append-char instead\n - bind_panel_toggles has ONE call site afterwards, which is the mechanical\n check that the duplication is actually gone\n\nNote gotcha 13: prefix keys are stored as an ADDRESS and resolved via\nKeymaps::prefix_keymap, which walks parents -- so RecordsNormal already reaches\nNormal's g/y/Z that way. prefix_keymaps_are_flat must still pass; a global\nkeymap must not introduce a nested prefix.\n\nThe good news from the original investigation stands and is not affected: the\nkeymaps are genuinely SHARED between the two front ends. Keymaps::shared() is a\nOnceLock over default_keymaps itself, so there is one definition and two callers,\nnot two copies held in step by a test.","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T00:33:38Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:33:55Z","labels":["protocol","ux"],"dependencies":[{"issue_id":"improvise-636d","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uln5","title":"The help overlay advertises 8 black-hole keys and a stranded command page, and no guard sees it","description":"MEASURED under Chromium at 523d571 from a fresh page, one key at a time, with\nthree controls (C, P, T) that DID move -- so \"nothing happened\" is a measurement\nand not a blind probe:\n\n NOTHING / search\n NOTHING n search-navigate\n NOTHING N search-or-category-add\n NOTHING H hide-selected-row-item\n NOTHING z toggle-group-under-cursor\n NOTHING Enter enter-advance\n NOTHING Control+s save\n NOTHING Control+q force-quit\n MOVED C / P / T (controls)\n\nEight of the eleven names in the_command_names_that_still_need_the_host_are_these\nare advertised in the help overlay the BROWSER RENDERS. Help page 5 additionally\nadvertises the entire ':' command line (:add-cat, :w, :q, :import, :export...),\nand CommandMode is stranding()'d, so that whole page is unreachable too.\n\nWHY THE EXISTING GUARD CANNOT SEE THIS. every_advertised_command_answers_in_its_own_mode\nreads help::advertised, which is the HINT-LINE table only. search-or-category-add\nis correctly absent from AppMode::Normal's hint -- the guard is right about what\nit checks. The help OVERLAY (help.rs::pages()) is a SECOND, INDEPENDENT body of\nadvertising in the same file, and nothing cross-references it against resolve.\n\nclients.md's own severity rule -- \"a key named in the mode's own hint line doing\nnothing reads as BROKEN\" -- applies with full force to the overlay and was never\nextended to it. Two bodies of advertising, one guarded.\n\nTHE STRUCTURAL OBSTACLE, which is the real cost and is why this is not trivial:\nhelp.rs::keys(mode, command, args, description) knows the command name AT\nCONSTRUCTION and then THROWS IT AWAY -- HelpLine::Keys { keys: Vec\u003cString\u003e,\ndescription } keeps only rendered key labels. So the analogous guard cannot be\nwritten without first carrying command/mode/args onto the variant. Small, but\nnot zero, and it has to happen before any check can exist.\n\nNote improvise-wk18 is the sibling already filed for help.rs's literal() lines,\nwhich the same guard cannot see for a related reason (a key string with no\ncommand name). Whoever takes either should read both -- they are one change to\nHelpLine's shape.\n\nSee also improvise-koh6 (P1): this is not merely cosmetic. Following the overlay's\nadvertised N corrupts a data cell, because the keystrokes that follow a dead key\nland on live NORMAL bindings and `i` opens an editor.\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":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:24Z","started_at":"2026-09-24T00:07:20Z","closed_at":"2026-09-24T21:45:24Z","close_reason":"Landed in main via #264 (reviewed; merged 2026-09-24).","labels":["browser","testing","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z336","title":"[bug] A page selection is never cleared, so cycling a category's axis hides a filter and cycling back restores it","description":"Reported by the user against the in-browser app: after clicking an item in the\ncategories panel there is no way to clear the filter it applies.\n\nMECHANISM, read at 523d571 in improvise-protocol/src/resolve.rs.\nclick_category_row -\u003e filter_to_item_at (resolve.rs:906) emits, for an Item row:\n\n SetAxis { category, axis: Axis::Page }\n SetPageSelection { category, item }\n SetStatus(\"Filter: {cat} = {item}\")\n\nThree separate problems, and the third is why the user could not find the way out.\n\n1. IT IS NOT A TOGGLE. Clicking the SAME item again emits the identical pair, so\n the gesture is idempotent rather than reversible. Every other click surface in\n this panel has an inverse -- a Category row calls toggle_cat_expand_at -- so\n the one gesture that changes the DOCUMENT is the one with no way back.\n\n2. IT SILENTLY DESTROYS THE CATEGORY'S PREVIOUS AXIS. SetAxis overwrites Row,\n Column or None with Page and nothing records what it was. So even a future\n \"clear\" cannot restore the prior layout; the best it could do is guess None.\n The status line says \"Filter: ...\" while what actually happened is a LAYOUT\n change, which is also why it reads as undoable when it is not.\n\n3. THE ESCAPE ROUTE EXISTS AND IS UNDISCOVERABLE. click_tile (resolve.rs:148)\n cycles a tile's axis on a SECOND click of an already-selected tile, so\n Page -\u003e None is reachable by clicking the category's tile twice. But:\n - nothing says so, and the status line points at \"Filter\", not the tile bar\n - it cycles Row-\u003eColumn-\u003ePage-\u003eNone, so \"clear\" is up to three more clicks\n - the TUI's other route, [ and ] page cycling, has no browser affordance:\n improvise-p7lh records that the browser draws NO PAGE BAR at all, and\n GridCache::page_coords does not reach RenderModel\n\nSo the feature is reachable in principle and absent in practice, which is worse\nthan absent -- the user concludes it cannot be done.\n\nWHAT A FIX HAS TO DECIDE, and it is a product call rather than a bug:\n - Should a second click on the SAME item clear the filter? That makes the\n gesture reversible and is what \"filter\" implies. It also needs an answer for\n what axis the category returns to, given (2) destroyed it.\n - Or should clicking never move a category to Page at all, and instead set a\n page SELECTION only when the category is already on Page, leaving the\n layout alone? That is the smaller, more honest gesture and it makes (2)\n moot -- but it changes what the click does for every category not already\n paged.\n - Either way the page bar wants painting (improvise-p7lh), because that is\n where a paged category is visible and cyclable in the TUI, and its absence\n is what makes the state feel trapped.\n\nNote gotcha 27: Page and Filter are different axes, and only Page is navigable.\nThis code uses Page, correctly -- but the STATUS LINE calls it a filter, which\nis the vocabulary of the non-navigable one. Worth aligning whichever way the\nproduct call goes.\n","notes":"MECHANISM CORRECTED 2026-09-21 by driving it under Chromium. My original code-read was right about the click path and WRONG about the way out, and the real defect is one neither the report nor I named.\n\nCONFIRMED: filter_to_item_at emitting SetAxis{Page} + SetPageSelection + the status is correct. Clicking an item gives 'Filter: Product = Espresso', the tile goes Row -\u003e Pag, the grid collapses 4 rows to 1. A SECOND click on the same item is a genuine no-op -- not a toggle. Clicking a DIFFERENT item switches the filter, so you can move a filter but not lift it.\n\nWRONG IN MY READ: the tile-bar double-click is NOT the only way back, and the tile bar IS visible and clickable in the shipped page (#tiles .tile, five of them). There is a second, KEYBOARD route: Space in the category panel (cycle-axis-at-cursor), which is advertised in that mode's own hint as 'Space:cycle-axis' and works directly after the click with no re-focus. So the escape is more discoverable than I claimed.\n\nTHE ACTUAL DEFECT: NEITHER ROUTE CLEARS ANYTHING. They only hide the filter, and it comes back. Measured, cycling Product all the way round:\n\n Pag(Espresso) -\u003e None (63,700, unfiltered)\n -\u003e Row (all 4 products)\n -\u003e Col\n -\u003e Page -\u003e 18,400 \u003c- Espresso is BACK, unasked\n\nView::set_axis (improvise-core/src/view/types.rs:118) does NOT touch\npage_selections. Only on_category_removed and on_item_removed ever clear one. So\nthe selection survives every axis move and is silently reinstated the moment the\ncategory returns to Page.\n\nThere is NO ClearPageSelection anywhere -- not in ModelCommand, not in the View\nAPI, AND NOT IN THE TUI. So this is a product gap at parity, not a browser\nregression. The browser is simply the first place anyone tried it with a mouse.\n\n[ and ] cannot clear it either: measured, they cycle the page selection among the\ncategory's items (18,400 -\u003e 17,100 -\u003e 15,800 -\u003e 20,300 -\u003e 24,600) with no 'All'\nentry to land on.\n\nTHIRD FINDING, and it may be what produced the original report: THE STATUS LINE\nLIES. 'Filter: Product = Espresso' stays on screen after the axis has gone to\nNone, Row and Col, and after five ] presses moved the page to a different item.\nThe one piece of feedback about the filter is stale exactly while the user is\ntrying to get rid of it -- so a user who DID clear it correctly is told for the\nrest of the session that it is still on. That mechanism can manufacture this\nexact bug report from a working code path.\n\nSilent-Space is split out as improvise-25bf.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-22T00:17:00Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:02:18Z","started_at":"2026-09-23T15:00:20Z","closed_at":"2026-09-23T15:02:18Z","close_reason":"Already fixed by 7a446166 (PR #114): View::set_axis forgets the page selection when a category leaves Page/Filter. Pinned by moving_a_category_off_the_constraining_axes_forgets_its_page_selection. Verified at gh/main 33cf496 by agent-refuse-fu.","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-z336","depends_on_id":"improvise-p7lh","type":"related","created_at":"2026-09-22T00:32:50Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"7a9089a4-34de-5d21-be5a-e20e249b9101","issue_id":"improvise-z336","author":"agent-refuse-fu","text":"agent-refuse-fu 2026-09-23: ALREADY FIXED at gh/main 33cf496. Fixing commit 7a446166 (\"fix(panel): five category-panel and axis defects, four of them P1\", merged via PR #114) makes View::set_axis forget the page selection when a category leaves the constraining axes (Page/Filter). Pinned by view::types::tests::moving_a_category_off_the_constraining_axes_forgets_its_page_selection and its companion moving_a_category_from_page_to_filter_keeps_its_page_selection; evidence in context/test-evidence/core.md. Confirmed 7a446166 is an ancestor of HEAD. Not closed by me; the orchestrator decides.\n","created_at":"2026-09-23T15:01:49Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_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":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-23T18:17:08Z","started_at":"2026-09-23T18:17:08Z","lease_expires_at":"2026-09-23T18:22:08Z","heartbeat_at":"2026-09-23T18:17:08Z","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-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":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-21T23:03:30Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:44:59Z","started_at":"2026-09-23T18:17:08Z","closed_at":"2026-09-24T21:44:59Z","close_reason":"Landed in main via #254 (reviewed; merged 2026-09-24).","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"],"dependencies":[{"issue_id":"improvise-mszy","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"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":"closed","priority":2,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-21T22:49:20Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:14:41Z","started_at":"2026-09-23T04:12:54Z","closed_at":"2026-09-23T05:14:41Z","close_reason":"Fixed in PR #181, merged as 810077a. Adversarial review passed at 12171fb; the only later changes were merges of gh/main, touching evidence files only (verified at 2619d17). Follow-ups: improvise-514o, and improvise-v4hg (formula-edit parse arm).","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":"closed","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-23T04:28:19Z","started_at":"2026-09-23T03:33:57Z","closed_at":"2026-09-23T04:28:19Z","close_reason":"PR #172 MERGED (confirmed at 119f84f), adversarially reviewed then re-reviewed after fixes (merge). h3y7: the forward vocabulary guard (ui/reduce/vocabulary_is_complete.rs) requires every Effect to be a same-named Command/ModelCommand variant or carry a NOT_ON_THE_WIRE verdict (Command/Frame/Off/Gap), checked in both directions. hkbc: position_free destructures every ModelCommand with no '..'. c7mu: one shared scanner, improvise_protocol::source_scan::production_code, blanks comments and literals and drops test code for BOTH vocabulary guards. 94x2: name_after skips attributes, so an attributed simple_effect! is still seen; re-review injected three attribute spellings and all were caught. Residual: improvise-59q8 (P3, the scanner's latent line-scan holes).","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-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":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-25T00:59:58Z","closed_at":"2026-09-25T00:59:58Z","close_reason":"Landed in #148 (on gh/main)","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":"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":"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":"in_progress","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-23T18:17:09Z","started_at":"2026-09-23T18:17:09Z","lease_expires_at":"2026-09-23T18:22:09Z","heartbeat_at":"2026-09-23T18:17:09Z","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-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":"closed","priority":2,"issue_type":"bug","assignee":"agent-mergetrain","owner":"box@local","created_at":"2026-09-21T20:49:03Z","created_by":"external-grok-bot","updated_at":"2026-09-24T21:44:59Z","started_at":"2026-09-23T18:17:09Z","closed_at":"2026-09-24T21:44:59Z","close_reason":"Landed in main via #254 (reviewed; merged 2026-09-24).","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} {"_type":"issue","id":"improvise-dhw5","title":"CSV analyzer misclassifies mostly-numeric columns with nulls as Label","description":"## Summary\n\n`analyze_records` `all_numeric` excludes `Value::Null`, so a column that is 99% numeric with one null becomes Label rather than Measure. Sparse numeric CSV columns lose numeric typing (numbers become stringified item names).\n\n## Steps to Reproduce\n\n1. Import CSV \"Revenue\" with 100 numeric rows and one null.\n2. Column classifies as Label; numeric values are not Measure cells.\n\n## Acceptance Criteria\n\n- Null/empty cells do not force Label when non-null values are uniformly numeric; sparse measures stay measures.\n\n## Location\n\n`crates/improvise-io/src/import/analyzer.rs` — `analyze_records` ~L124-135\n\n## PR / Attribution\n\nPR #100 F09. 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:02Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:26:10Z","closed_at":"2026-09-21T21:26:10Z","close_reason":"Duplicate of improvise-l1f5, the same defect found independently the same day: analyze_records' all_numeric excludes Value::Null, so a numeric column with one blank is classified Label instead of Measure and its numbers become stringified item names.\n\nKeeping l1f5 because it names the exact location (crates/improvise-io/src/import/analyzer.rs:129-132) and carries a concrete repro (Region,Revenue with East,10 and West,\u003cblank\u003e). This report's mechanism sentence is the clearer statement of WHY and has been copied onto l1f5.","external_ref":"gh-pr-100-F09-label","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -238,11 +316,11 @@ {"_type":"issue","id":"improvise-4lgj","title":"Parser wildcards silently ignore unknown grammar rules","description":"## Summary\n\nThe `.improv` pest walker has multiple `_ =\u003e {}` arms (`walk_category_section`, `walk_view_section`, `walk_md`) that silently skip grammar rules the walker does not handle. Extending `improv.pest` with a new rule causes silent data loss on round-trip rather than a loud parse/walker mismatch.\n\n## Steps to Reproduce\n\n1. Add a new rule to `improv.pest` (e.g. `color_line`), emit it in `format_md`.\n2. Parse with `parse_md` — no error; data from the new rule is dropped.\n3. Observe grammar accepts / walker ignores.\n\n## Acceptance Criteria\n\n- Unknown/new grammar rules the walker does not handle fail loudly (or exhaustive matches force updates); round-trip cannot silently drop accepted rules.\n\n## Doctrine\n\n`doctrine(check_the_checker)` — a parse that silently ignores valid grammar rules is indistinguishable from one that rejects them.\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` ~L180, L293, L315\n\n## PR / Attribution\n\nPR #100 F04 (P2 section; parser wildcards). 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:00Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:17Z","closed_at":"2026-09-21T21:58:17Z","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\nThree _ =\u003e {} arms now bail!, plus an exhaustive match over Rule naming the walker that reads each one -- a new grammar rule stops that test COMPILING. It earned its keep immediately by naming Rule::section, which the first draft missed.\n\nTHE FINDING: this is not merely hygiene, it is what made the other five persistence fixes safe. Because walk_file now bails, the pre-existing grammar-walking property test became a live grammar-vs-walker agreement check, so every rule added to improv.pest was exercised with no new test written. Under the old _ =\u003e {} one could have shipped ## Aggregation in the grammar with no walker arm and the whole suite would have stayed green.\n\nTwo of the three silent-wildcard beads (improvise-492x, improvise-vyrx) also closed this wave.","external_ref":"gh-pr-100-F06-parser","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-df2w","title":"Open empty formula panel is invisible: RenderModel encodes closed as empty rows","description":"## Summary\n\n`RenderModel` carries an explicit `category_panel_open` flag (and `web/app.js` hides `#categories` from it), but `views` / `formulas` visibility is encoded as “empty list means closed” (`paintPanel`: `host.hidden = rows.length === 0`). An **open** formula panel with **zero** formulas therefore paints as closed while mode is still FORMULAS.\n\n## Repro\n\n1. Open a document with no formulas (empty model, or strip formulas from demo).\n2. Press `F` (toggle-panel-and-focus formula → `SetPanelOpen{open:true}` + `ChangeMode(FormulaPanel)`).\n3. Observe mode indicator FORMULAS / hint for formula panel, but `#formulas` stays `hidden` because `formula_rows` returns `[]` when the cache is empty even though `formula_panel_open` is true.\n\n## Expected\n\nOpen panel chrome is visible (empty list UI), matching category panel’s flag-based visibility — or RenderModel exposes `formula_panel_open` / `view_panel_open` and the page uses those flags.\n\n## Actual\n\n`formula_rows` / `view_rows` gate on the open flag **and** content; page treats emptiness as closed. Mode and panel chrome disagree.\n\n## check_the_checker\n\n`a_closed_panel_projects_no_rows_and_an_open_one_does` only opens panels after projecting nonempty `ViewList`/`Formulas`. Injecting `formula_panel_open=true` with an empty formulas cache still yields `model.formulas == []`; the existing assert never sees the gap.\n\n## Doctrine\n\n- design-principles §5 (front end paints; decisions in shared layer) / clients.md RenderModel “empty list means closed” for help — applied to formulas without a flag, unlike categories\n- `doctrine(check_the_checker)`\n\nRelated but distinct from improvise-x0dc (Esc leaving `formula_panel_open` true with **nonempty** formulas so chrome stays).\n\n## Attribution\n\nexternal Grok Bot adversarial code review for Ed Langley\n\n## Location\n\n- `crates/improvise-wasm-client/src/render.rs` — `formula_rows` / `view_rows` / missing flags on `RenderModel`\n- `web/app.js` — `paintPanel`","notes":"## Steps to Reproduce\n1. Load a document with zero formulas (or empty workbook).\n2. Press F to open/focus the formula panel.\n3. Mode shows FORMULAS; #formulas remains hidden.\n\n## Acceptance Criteria\n- Open+empty formula panel paints visible empty chrome (or explicit formula_panel_open flag drives visibility like category_panel_open).\n- A unit/harness check fails if formula_panel_open=true with empty formulas cache but the page/model report the panel closed.\nReopened by the orchestrator: it was closed by mistake. The close was chained after 'gh pr merge 173' without checking the result, and the merge FAILED because #173 went CONFLICTING/DIRTY once #169 (ktlk) merged; both touch the browser RenderModel. The fix is reviewed (MERGE at 2186e3d) but not merged. Close again only after #173 actually merges.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-webpage","owner":"box@local","created_at":"2026-09-21T20:43:16Z","created_by":"box","updated_at":"2026-09-23T04:26:09Z","started_at":"2026-09-23T03:48:40Z","closed_at":"2026-09-23T04:26:09Z","close_reason":"PR #173 MERGED (confirmed at de73ad9). Reviewed at 2186e3d (merge), then re-reviewed after two conflict-resolution merges (merge). CI green per pr-watch --expect. RenderModel::views/formulas are Option\u003cVec\u003c_\u003e\u003e: None is closed, Some([]) is open and empty. The re-review confirmed #169's editor derivation survived and nothing re-derives the seed. (Previously closed by mistake, when a close was chained after a merge that had failed; reopened, and now closed after the merge was confirmed.)","labels":["adversarial-review","wasm-client","web"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jkxr","title":"parse_duplicate_categories is vacuous: cannot fail for a merge that drops half the items","description":"## Summary\n\n`persistence::parse::parser_edge_cases::parse_duplicate_categories` only asserts `!item_names.is_empty()` after parsing two `## Category: Type` sections (`- A` then `- B`). That cannot fail for the merge defect the test name and placement imply.\n\n## Evidence / fault injection\n\nProduction merge (`ParsedFile::push_category`) concatenates items on duplicate section names (`invariant(duplicate_category_sections_merge_in_pass_one)`). The nearby test `two_sections_naming_one_category_round_trip_to_a_stable_group_order` does assert both halves survive for groups.\n\nInjected fault mental model: change `push_category` to **replace** the first section with the second (keep only `B`), or keep only the first (`A`). Assert `!item_names.is_empty()` still passes. Only a total wipe of items fails.\n\nExpected assertion shape: both `A` and `B` present (order/content), matching the merge invariant — same standard as the group round-trip neighbour.\n\n## Doctrine\n\n- `doctrine(check_the_checker)` — a check nobody has seen fail for the named defect is indistinguishable from one that always passes\n- `invariant(duplicate_category_sections_merge_in_pass_one)`\n- design-principles §7 testing doctrine\n\n## Attribution\n\nexternal Grok Bot adversarial code review for Ed Langley\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` — `parse_duplicate_categories` (~L1096)","notes":"## Steps to Reproduce\n1. Open crates/improvise-io/src/persistence/parse.rs::parse_duplicate_categories.\n2. Mentally change ParsedFile::push_category to replace (not extend) on duplicate name.\n3. Re-run the test — it still passes.\n\n## Acceptance Criteria\n- Test asserts both items A and B are present after parsing the two Type sections.\n- Injecting a replace-not-extend merge makes the test fail.","status":"closed","priority":2,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:43:15Z","created_by":"box","updated_at":"2026-09-21T21:25:59Z","closed_at":"2026-09-21T21:25:59Z","close_reason":"Fixed in 2b086b2 (formatting follow-up e4adf86) on main.\n\nThe finding was verified by injection rather than taken on report: changing\npush_category's 'existing.items.extend(items)' to 'existing.items = items' --\nso a second section REPLACES the first -- left the old assertion PASSING. The\nstrengthened assertion fails with left: [\"B\"], right: [\"A\", \"B\"].\n\nThe test now asserts assert_eq!(item_names, [\"A\", \"B\"]), pinning order as well\nas membership, since insertion order is load-bearing here (gotcha 18). That is\nthe standard its neighbour two_sections_naming_one_category_round_trip_to_a_stable_group_order\nalready held groups to; this was the item-list half of the same invariant held\nto a weaker one.\n\nEvidence recorded in context/test-evidence/formula-and-io.md.","labels":["adversarial-review","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_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-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":"{}"},{"issue_id":"improvise-12ha","depends_on_id":"improvise-tm6","type":"parent-child","created_at":"2026-09-25T01:04:35Z","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":"closed","priority":2,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:58Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:28:40Z","started_at":"2026-09-23T04:38:11Z","closed_at":"2026-09-23T05:28:40Z","close_reason":"Fixed in PR #196, merged after adversarial review at 6b3dd8c. Follow-ups: improvise-w1bu (test gap), improvise-pakc (totals identity).","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":"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-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-6jk","type":"parent-child","created_at":"2026-09-25T01:04:38Z","created_by":"Edward Langley","metadata":"{}"},{"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":"closed","priority":2,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:44:50Z","started_at":"2026-09-23T03:55:04Z","closed_at":"2026-09-23T05:44:50Z","close_reason":"Fixed in PR #180, merged after adversarial review (content reviewed at 544744c; the tip-removal delta and the evidence-only merges were reviewed at f248827). Follow-ups: improvise-1c5m, improvise-17ak.","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":"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} @@ -278,11 +356,11 @@ {"_type":"issue","id":"improvise-zv8t","title":"fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable","description":"fit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable\n\nFound reviewing improvise-wd6n's branch, and it was very nearly foreclosed in\nprose: the first draft of context/code/gp.md §11 called Artefact::NumericCategory\na permanent refusal that \"no vocabulary closes\". That is wrong, and this is the\nticket the prose would have prevented.\n\ncrates/improvise-gp/src/fit.rs passes `\u0026Overrides::default()` to `preprocess`,\nso it never tells preprocess which header columns are document categories --\nalthough the very next line does tell `Features::of`. `preprocess` then infers\nNumeric -\u003e Date -\u003e Categorical from the cell values, so:\n\n * a regular category whose items all parse as numbers (a `Year` category with\n items 2024/2025) becomes ColumnKind::Numeric, and its feature is refused as\n Artefact::NumericCategory\n * a regular category whose items are all ISO dates becomes ColumnKind::Date,\n and its features are refused as Artefact::DatePart\n\nIn both cases `Year IS 2024` is EXACTLY spellable, and the fit is refused\nanyway. Unlike the two families improvise-wd6n declines, this needs no\nformula-language change at all: `Overrides::force[col] = Forced::Categorical`\nalready exists (gp-tree/src/data.rs, consumed in preprocess's `match ov.force`)\nand is the knob. fit.rs knows which columns are categories -- it computes\n`category_columns` on the line above.\n\nThe refusal text for NumericCategory argues about `Expr::Ref` resolving to a\n_Measure item of the same name, which was never the right spelling for a\ncategory column. It also sits awkwardly beside this crate's own rule (gotcha\n37(d), gp.md §11: the check asks the document, never the name's shape) and\nbeside a_coordinate_is_spelled_as_one_even_when_an_item_shares_the_one_hots_name,\nwhich exists precisely to show the two spellings cannot collide.\n\nNeither NumericCategory nor AbsentCoordinate has a test today; they were\ninvented by the wd6n branch and only three of the five families are reached by\nits fixture.\n\n## Acceptance\n\nA fit over a view with a numeric-valued or date-valued regular category emits a\n`Cat IS Item` coordinate for it rather than being refused, demonstrated by a\ntest that fails before the fix. Whatever forcing is applied must not change how\na MEASURE column is inferred -- a numeric measure is still Numeric. If forcing\nturns out to be wrong for some reason not visible from here, say so in\ncontext/code/gp.md and correct the \"permanent refusal\" paragraph either way.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T05:05:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:09:28Z","closed_at":"2026-09-21T11:09:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t6db","title":"grid_in ignored prune_empty for a non-active view, hiding a staleness bug","description":"FIXED in PR #78; filed because the FINDING is worth more than the fix and because the shape has now recurred three times.\n\nproject::grid_in built a layout for a view the host is NOT on with GridLayout::new, which does not apply prune_empty. So a client on another view was projected rows its own view config prunes. Measured on a two-view fixture where both views prune and one row is empty:\n\n Second via grid_in (GridLayout::new) 3 rows: North, Service, South\n Second via with_frozen_records (pruned) 2 rows: North, South\n\nOne line. It broke no other test, which is itself evidence nothing depended on the unpruned behaviour.\n\nWHY IT MATTERS MORE THAN ITS SIZE -- IT WAS A BUG HIDING A BUG. The adversarial review of #78 found a P1: after improvise-mcq4 narrowed the cursor fingerprint to the active view, a CellData write that prunes a row out of ANOTHER client's view stopped invalidating that client. Writing the regression test for it FAILED AT THE PRECONDITION -- the bystander's projected grid never lost a row, because grid_in never pruned it. The staleness was real and logically reachable, and completely unobservable through the projection. Fixing grid_in is what made the P1 demonstrable, and the P1 test is what found grid_in.\n\nTHIRD INSTANCE OF ONE TRAP: GridLayout::new does not prune; GridLayout::with_frozen_records does. Previously bitten by App::view_frame (improvise-ni6k, caught by a failing test) and by the reshape benchmark's geometry fixture (improvise-mcq4, caught only by measuring). A constructor whose name suggests it is the general one, and which silently omits a view setting, is worth a look on its own terms -- either new should prune, or the names should say which is which.\n\nSTILL OPEN AROUND IT, not fixed here: nothing asserts that a client on a non-active view is projected a grid matching ITS OWN view config in general. prune_empty is one setting; the rest is a guarantee by inspection rather than by test.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T05:00:08Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:35Z","closed_at":"2026-09-21T12:39:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ijql","title":"check-comment-runs.py --check-evidence silently skips the comment-run cap it is named for","description":"UPDATE 2026-09-21, after PR #83 merged: the DOCS half is done — the code-review skill's gate list and every wave brief now run both spellings. The CODE half is NOT done and this ticket is about that: scripts/check-comment-runs.py:1010 still early-returns on args.check_evidence, so the flag SELECTS rather than adds and the flagged run still says nothing about the cap it skipped.\n\nThree agents hit this independently in one campaign, which is the argument for fixing it rather than documenting around it. The reviewer's position was that CI runs all three forms (ci.yml L144/146/148) so this is documentation only; the counter is that the flagged form is the EXPENSIVE one (it needs a compiled workspace), which makes it the one a human runs alone, and a gate whose skip is silent is the defect class this repo keeps finding.\n\nTwo shapes: make --check-evidence additive (run the cap too), or make the flagged run print one line saying the cap did not run. The second is cheaper and matches what PR #83 did for --rust-tests, where the fix was to make the success line name its source.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:31:48Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:49Z","closed_at":"2026-09-21T05:23:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mcq4","title":"The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized","description":"MEASURED, not suspected. Found while merging PR #78 (improvise-ni6k) with PR #76 (improvise-748f), which landed first.\n\nimprovise-748f split the geometry fingerprint so that a batch which writes NO cell -- the held-key path, [/] and axis cycle and hide/show -- stays O(config) instead of walking 18,000 cells. improvise-ni6k's App::view_frame builds a GridLayout per view via with_frozen_records, and with_frozen_records applies prune_empty, and prune_empty WALKS CELLS. So for a view with prune_empty on, ni6k puts the O(cells) walk straight back onto the path 748f had just taken it off.\n\nMEASURED on the merged tree, benches/reshape_observation.rs, one CycleAxis batch, prune_empty ON, frame fingerprint disabled vs enabled:\n\n 400 cells 93.6us -\u003e 283.2us 3.0x\n 9,600 cells 1.97ms -\u003e 7.34ms 3.7x\n 18,000 cells 3.04ms -\u003e 12.41ms 4.1x\n 90,000 cells 28.5ms -\u003e 91.7ms 3.2x\n\nThe 16.7ms frame budget is crossed at 18,000 cells, where the pre-fingerprint tree sat comfortably inside it at 3.0ms.\n\nWHY 3-4x AND NOT 2x: view_frames builds every view's layout, before AND after each model effect. The bench fixture has two views, so that is four pruned layout builds against the one rebuild_layout already does.\n\nWHY THE COST WAS NOT SEEN EARLIER: ni6k's own benchmark measures a SetCell batch, where 748f's document_content walk dominates and the fingerprint reads as +2 to +5 percent. And the geometry case, when first added, used the dense_app fixture as-is -- prune_empty OFF -- where with_frozen_records does not walk cells at all and a CycleAxis batch reads 17-176us. THAT FIXTURE MEASURES THE CHEAP HALF AND CALLS IT THE COST. It is the same non-discriminating-fixture failure this repo names everywhere; the numbers above come from turning prune_empty on, and the benchmark now does.\n\nOPTIONS, none taken:\n- Fingerprint only the ACTIVE view for the cursor and fall back to the cheaper config hash (last_reshaped_views) for the client audience. Over-resets bystanders on other views, which is the safe direction per gotcha 35, and removes the per-view multiplier. Probably the right answer.\n- Memoise the pruned layout across the before/after pair.\n- Accept, on the argument that prune_empty plus a large document plus a held key is rare. That argument needs the user, not an agent.\n\nDO NOT close this by re-running the benchmark with prune_empty off.","notes":"FIXED to 1.9x, not closed: one pruned build per model effect remains.\n\nThe coordinator ruled this must be fixed before PR #78 lands, and was right -- a cost measured to four significant figures is a decision to ship, not a thing to file.\n\nWHAT WORKED, AND IT IS NOT WHAT THE TICKET PROPOSED. The ticket's first option (and my own suggestion) was to fingerprint only the ACTIVE view and fall back to the config hash for the client audience. Implemented and measured: it bought NOTHING -- 12.41ms -\u003e 12.56ms at 18,000 cells. The bench's second view is one created fresh with everything on Axis::None, so building it costs nothing. THE PER-VIEW MULTIPLIER WAS NEVER THE COST; two EXPENSIVE builds were. That is only known because it was measured after implementing rather than reasoned about before.\n\nWHAT ACTUALLY WORKED: the opening basis is free. At batch start App::layout already IS the active view's pruned layout, so opening_view_frame() hashes it instead of building a second one. frame_of() is the shared hash and view_frame() builds then calls it.\n\n one CycleAxis batch, prune_empty ON:\n before after fingerprint off\n 400 cells 283.2us 167.0us 93.6us\n 9,600 cells 7.34ms 3.879ms 1.967ms\n 18,000 cells 12.41ms 5.752ms 3.043ms\n 90,000 cells 91.69ms 56.54ms 28.52ms\n\n4.1x -\u003e 1.9x at the canonical size, back inside the 16.7ms frame budget.\n\nTwo guards on the free basis: it is gated on drill_state.is_none(), because a drill freezes the record list and the held layout is then not what view_frame would build; and it is opening-use only, because App::layout is stale mid-batch (gotcha 23).\n\nTHE NARROWING WAS KEPT anyway, because it is the honest shape -- the cursor's question is about one view. The audience half is now the config diff observe_reshape already computes free. It over-reports (safe direction) and is read only behind the epoch gate in adopt_cursor_epoch, so its imprecision cannot reach a batch that reframed nothing. a_reshape_of_one_view_leaves_a_client_on_another_view_alone still passes, so it has not widened into improvise-nynk's negative arm.\n\nBOTH SUBSUMPTION PROOFS RE-RUN AFTER THE NARROWING, since narrowing is exactly what could quietly stop covering something: #76's own two arms still pass with no per-effect reset anywhere; AddFormula/RemoveFormula and SortData are still covered; disabling the narrowed observation fails ten tests.\n\nWHAT REMAINS, AND WHY THIS STAYS OPEN: one pruned layout build per model effect. The LAST one duplicates work rebuild_layout does immediately afterwards, and eliding it means moving the check after the loop -- which is the position that clobbers a deliberate cursor placement in the same batch (the three records-mode tests that caught it the first time). A restructure that knows which effect is last, including on abort, is possible and was not attempted at this hour.\n\nThe benchmark now carries the held-key case with prune_empty ON and a comment saying not to turn it off, because that fixture is the only reason any of this was visible.\nTHE NARROWING BOUGHT ITS SPEED PARTLY WITH CORRECTNESS, and the adversarial review of PR #78 caught it. Correcting my own note above.\n\nI narrowed the frame fingerprint from every view to the active one, measured that it moved 12.41ms to 12.56ms, kept it anyway \"as the honest shape\", and reported that it bought nothing. It bought a P1: a CellData write that prunes a row out of ANOTHER client's view became invisible. view_shapes is config-only -- it hashes the prune_empty FLAG, not which rows survive -- so last_reshaped_views is empty, the one-view frame covers only the host's, and the bystander is never sent ResetCursor while its grid loses a row. Gotcha 24's permanently-stale case.\n\nTHE LESSON I WANT ON THIS TICKET: a measurement showing a change is free is not evidence that it is correct. I answered \"what does this cost\" and reported the answer as though it had also answered \"is this right\". The two questions were asked in the wrong order and only one of them was asked at all.\n\nTHE REPAIR, and the cost trade is accepted deliberately rather than hidden:\n- LayoutGeometry arm keeps the cheap one-view frame. view_mut() writes only the active view, and the structural cases (AddCategory, RemoveCategory, Add/RemoveItem) are already covered by view_shapes.\n- CellData arm restores the all-views frame, because a cell write can prune a row out of any view.\n\n Measured, my own numbers, one batch, prune_empty ON:\n held-key (CycleAxis) 18k cells 5.69ms -- UNCHANGED, 0%\n SetCell 18k cells 10.66ms -\u003e 11.26ms +5.7%\n\nThe +5.7% scales LINEARLY IN VIEW COUNT on the CellData arm. A ten-view document pays it ten times. That is the trade and it is accepted, not hidden.\n\nTHE ABSOLUTE, stated plainly because it should not be discovered later: even after the mcq4 fix, this PR roughly DOUBLES held-key latency on large pruned documents -- 3.04ms to 5.69ms at 18,000 cells -- and 56ms at 90,000 cells is 3.4 frames. The 16.7ms budget is respected at the canonical size and is not respected at the pathological one, which was also true before at 28.5ms.\n\nA SECOND BUG FOUND WHILE WRITING THE REGRESSION TEST, filed as improvise-t6db: the test's precondition failed because grid_in built a non-active view with GridLayout::new, which does not prune. The bystander's projected grid never lost a row, so the P1 was unobservable -- a bug hiding a bug.\n\nSTILL OPEN, unchanged: one pruned build per model effect. The last duplicates rebuild_layout and can only be elided by moving the check after the loop, which clobbers a deliberate cursor placement in the same batch. With the arms now split, moving the LayoutGeometry arm alone after the loop fails 2 tests; the reviewer measured 4 against the unified arm before the split.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:20:08Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T05:00:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mcq4","title":"The frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized","description":"MEASURED, not suspected. Found while merging PR #78 (improvise-ni6k) with PR #76 (improvise-748f), which landed first.\n\nimprovise-748f split the geometry fingerprint so that a batch which writes NO cell -- the held-key path, [/] and axis cycle and hide/show -- stays O(config) instead of walking 18,000 cells. improvise-ni6k's App::view_frame builds a GridLayout per view via with_frozen_records, and with_frozen_records applies prune_empty, and prune_empty WALKS CELLS. So for a view with prune_empty on, ni6k puts the O(cells) walk straight back onto the path 748f had just taken it off.\n\nMEASURED on the merged tree, benches/reshape_observation.rs, one CycleAxis batch, prune_empty ON, frame fingerprint disabled vs enabled:\n\n 400 cells 93.6us -\u003e 283.2us 3.0x\n 9,600 cells 1.97ms -\u003e 7.34ms 3.7x\n 18,000 cells 3.04ms -\u003e 12.41ms 4.1x\n 90,000 cells 28.5ms -\u003e 91.7ms 3.2x\n\nThe 16.7ms frame budget is crossed at 18,000 cells, where the pre-fingerprint tree sat comfortably inside it at 3.0ms.\n\nWHY 3-4x AND NOT 2x: view_frames builds every view's layout, before AND after each model effect. The bench fixture has two views, so that is four pruned layout builds against the one rebuild_layout already does.\n\nWHY THE COST WAS NOT SEEN EARLIER: ni6k's own benchmark measures a SetCell batch, where 748f's document_content walk dominates and the fingerprint reads as +2 to +5 percent. And the geometry case, when first added, used the dense_app fixture as-is -- prune_empty OFF -- where with_frozen_records does not walk cells at all and a CycleAxis batch reads 17-176us. THAT FIXTURE MEASURES THE CHEAP HALF AND CALLS IT THE COST. It is the same non-discriminating-fixture failure this repo names everywhere; the numbers above come from turning prune_empty on, and the benchmark now does.\n\nOPTIONS, none taken:\n- Fingerprint only the ACTIVE view for the cursor and fall back to the cheaper config hash (last_reshaped_views) for the client audience. Over-resets bystanders on other views, which is the safe direction per gotcha 35, and removes the per-view multiplier. Probably the right answer.\n- Memoise the pruned layout across the before/after pair.\n- Accept, on the argument that prune_empty plus a large document plus a held key is rare. That argument needs the user, not an agent.\n\nDO NOT close this by re-running the benchmark with prune_empty off.","notes":"FIXED to 1.9x, not closed: one pruned build per model effect remains.\n\nThe coordinator ruled this must be fixed before PR #78 lands, and was right -- a cost measured to four significant figures is a decision to ship, not a thing to file.\n\nWHAT WORKED, AND IT IS NOT WHAT THE TICKET PROPOSED. The ticket's first option (and my own suggestion) was to fingerprint only the ACTIVE view and fall back to the config hash for the client audience. Implemented and measured: it bought NOTHING -- 12.41ms -\u003e 12.56ms at 18,000 cells. The bench's second view is one created fresh with everything on Axis::None, so building it costs nothing. THE PER-VIEW MULTIPLIER WAS NEVER THE COST; two EXPENSIVE builds were. That is only known because it was measured after implementing rather than reasoned about before.\n\nWHAT ACTUALLY WORKED: the opening basis is free. At batch start App::layout already IS the active view's pruned layout, so opening_view_frame() hashes it instead of building a second one. frame_of() is the shared hash and view_frame() builds then calls it.\n\n one CycleAxis batch, prune_empty ON:\n before after fingerprint off\n 400 cells 283.2us 167.0us 93.6us\n 9,600 cells 7.34ms 3.879ms 1.967ms\n 18,000 cells 12.41ms 5.752ms 3.043ms\n 90,000 cells 91.69ms 56.54ms 28.52ms\n\n4.1x -\u003e 1.9x at the canonical size, back inside the 16.7ms frame budget.\n\nTwo guards on the free basis: it is gated on drill_state.is_none(), because a drill freezes the record list and the held layout is then not what view_frame would build; and it is opening-use only, because App::layout is stale mid-batch (gotcha 23).\n\nTHE NARROWING WAS KEPT anyway, because it is the honest shape -- the cursor's question is about one view. The audience half is now the config diff observe_reshape already computes free. It over-reports (safe direction) and is read only behind the epoch gate in adopt_cursor_epoch, so its imprecision cannot reach a batch that reframed nothing. a_reshape_of_one_view_leaves_a_client_on_another_view_alone still passes, so it has not widened into improvise-nynk's negative arm.\n\nBOTH SUBSUMPTION PROOFS RE-RUN AFTER THE NARROWING, since narrowing is exactly what could quietly stop covering something: #76's own two arms still pass with no per-effect reset anywhere; AddFormula/RemoveFormula and SortData are still covered; disabling the narrowed observation fails ten tests.\n\nWHAT REMAINS, AND WHY THIS STAYS OPEN: one pruned layout build per model effect. The LAST one duplicates work rebuild_layout does immediately afterwards, and eliding it means moving the check after the loop -- which is the position that clobbers a deliberate cursor placement in the same batch (the three records-mode tests that caught it the first time). A restructure that knows which effect is last, including on abort, is possible and was not attempted at this hour.\n\nThe benchmark now carries the held-key case with prune_empty ON and a comment saying not to turn it off, because that fixture is the only reason any of this was visible.\nTHE NARROWING BOUGHT ITS SPEED PARTLY WITH CORRECTNESS, and the adversarial review of PR #78 caught it. Correcting my own note above.\n\nI narrowed the frame fingerprint from every view to the active one, measured that it moved 12.41ms to 12.56ms, kept it anyway \"as the honest shape\", and reported that it bought nothing. It bought a P1: a CellData write that prunes a row out of ANOTHER client's view became invisible. view_shapes is config-only -- it hashes the prune_empty FLAG, not which rows survive -- so last_reshaped_views is empty, the one-view frame covers only the host's, and the bystander is never sent ResetCursor while its grid loses a row. Gotcha 24's permanently-stale case.\n\nTHE LESSON I WANT ON THIS TICKET: a measurement showing a change is free is not evidence that it is correct. I answered \"what does this cost\" and reported the answer as though it had also answered \"is this right\". The two questions were asked in the wrong order and only one of them was asked at all.\n\nTHE REPAIR, and the cost trade is accepted deliberately rather than hidden:\n- LayoutGeometry arm keeps the cheap one-view frame. view_mut() writes only the active view, and the structural cases (AddCategory, RemoveCategory, Add/RemoveItem) are already covered by view_shapes.\n- CellData arm restores the all-views frame, because a cell write can prune a row out of any view.\n\n Measured, my own numbers, one batch, prune_empty ON:\n held-key (CycleAxis) 18k cells 5.69ms -- UNCHANGED, 0%\n SetCell 18k cells 10.66ms -\u003e 11.26ms +5.7%\n\nThe +5.7% scales LINEARLY IN VIEW COUNT on the CellData arm. A ten-view document pays it ten times. That is the trade and it is accepted, not hidden.\n\nTHE ABSOLUTE, stated plainly because it should not be discovered later: even after the mcq4 fix, this PR roughly DOUBLES held-key latency on large pruned documents -- 3.04ms to 5.69ms at 18,000 cells -- and 56ms at 90,000 cells is 3.4 frames. The 16.7ms budget is respected at the canonical size and is not respected at the pathological one, which was also true before at 28.5ms.\n\nA SECOND BUG FOUND WHILE WRITING THE REGRESSION TEST, filed as improvise-t6db: the test's precondition failed because grid_in built a non-active view with GridLayout::new, which does not prune. The bystander's projected grid never lost a row, so the P1 was unobservable -- a bug hiding a bug.\n\nSTILL OPEN, unchanged: one pruned build per model effect. The last duplicates rebuild_layout and can only be elided by moving the check after the loop, which clobbers a deliberate cursor placement in the same batch. With the arms now split, moving the LayoutGeometry arm alone after the loop fails 2 tests; the reviewer measured 4 against the unified arm before the split.\n","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:20:08Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T05:00:28Z","dependencies":[{"issue_id":"improvise-mcq4","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:06Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ubs","title":"A cyclic-successor all() can silently omit a variant while its doc says it cannot","description":"Found by the adversarial review of PR #78, which probed it rather than reading the doc comment.\n\nAppMode::all() and Rejection::all() walk a total, cyclic after(). The claim in both doc comments is that a new variant cannot be omitted, because after() has no None arm so the only way to answer for a new variant is to NAME ANOTHER ONE.\n\nThat is half true and the half it gets wrong is the half that matters. ANSWERING after() FOR a new variant is forced by the compiler. POINTING AN EXISTING ARM AT IT IS NOT. A probe variant whose after() arm returns some existing variant, with nothing in the cycle pointing back to it, is simply never walked: all().len() came back 17, not 18, with the suite green.\n\nSo the consolidation replaced five grep-visible lists with one derivation whose totality is asserted in prose and checked by nothing. THE REVIEWER'S META-POINT IS THE REAL TICKET: five disagreeing AppMode lists were visible to grep; all() under-enumerating is visible to nothing. A consolidation owes a test that the surviving derivation is TOTAL, not merely that it agrees with what it replaced.\n\nWHAT IS STILL BETTER THAN BEFORE, so this is not a revert: the compiler now forces a human to look, and two of the five lists it replaced were ALREADY WRONG (both missing SavePrompt). This is a gap in a guarantee, not a regression.\n\nOPTIONS, roughly in order of cost:\n- A test that walks the cycle and asserts every variant is visited, driven by something independent of after() -- which needs an independent enumeration, and that is the problem restated.\n- A derive macro or a dependency (strum). Rejected during PR #78 only because adding a workspace dep mid-wave collides in Cargo.toml; on its own it is the honest answer and cheap.\n- Accept and document the residual precisely, replacing both doc comments, which currently overclaim.\n\nWHATEVER IS CHOSEN, FIX THE DOC COMMENTS FIRST: they presently say the omission is impossible, and a reader checking this property will believe them. An overclaiming comment is worse than none, because it stops the next person looking.","notes":"A FOURTH INSTANCE, inside the PR that found this ticket. The adversarial review of PR #78 added a fourteenth `pub struct` declaring `EffectKind::Model(ModelChange::LayoutGeometry)`, pointed at by nothing, and the whole suite stayed green.\n\nThe site is layout_geometry_effects_actually_change_document_config in ui/effect.rs. Its comment said \"a fourteenth declaring LayoutGeometry must come here and say so\". That is false for exactly the reason this ticket names: adding is compiler-forced only where an EXISTING arm must point at the new thing, and here nothing must. `assert_eq!(cases.len(), 13)` is a count, not a totality check -- it catches a case being deleted from the list, never one being absent from it.\n\nCorrected in #78 to say what it is rather than what it is not; the mechanism is unchanged and the cursor consolidation does not depend on it. Filed here rather than fixed because the honest fix is whatever 5ubs settles on, and a fourth hand-rolled attempt in a fourth place is the disease.\n\nWHAT MAKES THIS ONE WORTH RECORDING: it is the same defect as AppMode::all(), in the PR whose own consolidation work surfaced AppMode::all(). A list that is checked for LENGTH reads exactly like a list that is checked for COMPLETENESS, and both are written as an assert_eq over a number.\n","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:27Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-349m","title":"format_md/parse_md is not idempotent when a .improv file has two Category sections with the same name","description":"format -\u003e parse -\u003e format differs, the two sides differing only in the order of '\u003e' group lines. DETERMINISTIC -- 8 failures in 8 runs on unmodified main (00f7678) -- so it is NOT gotcha 18's HashSet shape.\n\nCause: the file carries two '## Category: ||' sections with the SAME name. The second merges into the first and its group lines are appended in a position the second parse does not reproduce.\n\nReproducer, from the proptest's own minimal input:\n\n let file = \"v2025-04-09\\n# |\\\\a|\\n## View: a\\n_-: row\\ncollapsed: ||/A--\\n\\\n ## Category: ||\\n- |\\\\a\\\\a\\\\a|,|\\\\a\\\\a|,|\\\\a|,|\\\\a|\\n\u003e _---[|\\\\a|]\\n\u003e _[|\\\\a|]\\n\\\n ## Category: ||\\n- ||[||]\\n\u003e ||[||]\\n\";\n let m1 = parse_md(file).unwrap();\n let printed = format_md(\u0026m1);\n assert_eq!(printed, format_md(\u0026parse_md(\u0026printed).unwrap())); // fails\n\nWHY IT LOOKS FLAKY AND IS NOT: persistence::parse::grammar_prop_tests::generated_file_roundtrips goes red only when the grammar-walking generator happens to emit a duplicate section header, which is rare. It was observed once during PR #70's CI, the rerun passed, and the honest move was to take proptest's minimal input and try it deterministically rather than rerun and move on. That is what turned a 'flake' into a filed bug.\n\nFound by the improvise-pdvm/75ld agent on PR #70; not caused by that PR. bd search for idempotent, roundtrip, duplicate category, format_md and group order returned nothing, so it was not already filed.\n\nWorth deciding as part of the fix: is a duplicate '## Category:' header legal input at all? If it is not, the parser should refuse it and the generator should stop emitting it; if it is, the merge must produce a canonical group order. The round-trip property cannot hold without one of those answers.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:42:59Z","created_by":"orchestrator","updated_at":"2026-09-21T04:42:54Z","closed_at":"2026-09-21T04:42:54Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v5pc","title":"restore the browser assertion that ? opens help","description":"36ade35 moved the shell suite to node and deleted harness/tests/widgets.spec.js, taking the Playwright test '? opens help, and its rows are the shared ones' with it. Nothing replaced it: no node test and no spec asserts that the help overlay is drawn, that its active tab is the shared module's page title, or that l pages it. The evidence entry in context/test-evidence/clients.md survived the deletion and went on promising the coverage for a whole release -- the improvise-5ray cost, now caught by --check-evidence, and the heading is demoted to prose rather than deleted so its two injections (dropping the paintHelp call; removing the mode != Help guard in help_rows) survive for whoever restores it. improvise-ktv1's acceptance -- a browser shows help -- is currently unasserted end to end.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T23:25:28Z","created_by":"claude-agent-scripts","updated_at":"2026-09-21T00:06:02Z","closed_at":"2026-09-21T00:06:02Z","close_reason":"Duplicate of improvise-78v6, already fixed and merged in PR #72. harness/tests/help.spec.js on main carries test(\"? opens help, and its rows carry the keys and the words, not empty frames\") plus a second test that reads an advertised key out of the DOM, presses it, and asserts the selection moved.\n\nWorth noting HOW this duplicate happened, because the filing agent did nothing wrong: it searched bd before filing, as AGENTS.md requires, and found nothing -- because the fix was in a sibling agent's UNMERGED PR at the time. A parallel wave makes in-flight work invisible to bd search. The cheap addition to the wave brief is to check open PRs, not only the tracker, before filing something a sibling unit might own.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"deferred","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-22T06:22:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xfsb","title":"Agents should post to GitHub under their own identity, not the owner's","description":"Today every agent comment, review and PR goes out as @fiddlerwoaroof, because gh uses his credentials. The interim is a convention -- open each comment with a line saying a model wrote it (doctrine(an_agent_posting_to_github_says_so), AGENTS.md) -- and a convention is something an agent can forget. A separate identity makes attribution structural instead.\n\nTwo routes:\n\n1. GitHub App installed on the repo. Agents mint an installation token and export GH_TOKEN. Comments render as \u003cname\u003e[bot], the same shape as copilot-pull-request-reviewer[bot]. Scoped permissions, no seat consumed, revocable independently of the owner's account, and the bot suffix is not spoofable by a human account.\n\n2. Machine user with its own PAT as GH_TOKEN. Simpler to set up, consumes a seat on some plans, and is indistinguishable from a person at a glance -- which is most of what we are trying to fix.\n\nPrefer 1. Needs the user: create the App, grant contents+pull_requests+issues write, install it, and put the app id and private key where agents can reach them.\n\nWorth checking while doing it: whether the bot can be requested as a PR reviewer, which would let review agents post as reviews rather than as comments.\n\nOnce it lands, the attribution line becomes belt-and-braces rather than the mechanism, and the doctrine should be amended to say so rather than deleted -- a reader seeing an unattributed comment from the owner's account still needs to know it might be an agent.","notes":"DEFERRED by the user 2026-09-22. Recording the reasoning, because the status was\nset with none and the INTERIM IS LOAD-BEARING WHILE THIS SITS.\n\nWHY IT IS A FINE THING TO DEFER: route 1 needs the user to create a GitHub App,\ngrant it contents+pull_requests+issues write, install it, and put the app id and\nprivate key where agents can reach them. That is credential work only he can do,\nand nothing in the codebase is blocked on it.\n\nWHAT MUST STAY IN FORCE MEANWHILE, and this is the part that would be quietly\nlost: EVERY agent comment, review, PR body and issue still opens with a line\nnaming the model and that it is an agent posting in his session\n(doctrine(an_agent_posting_to_github_says_so), AGENTS.md). While this ticket is\ndeferred that convention is THE MECHANISM, not belt-and-braces -- an\nunattributed comment reads as the owner's own words to everyone who sees it.\nDeferring the structural fix therefore RAISES the cost of forgetting the\nconvention rather than lowering it.\n\nMeasured this session: the convention held across four merged PRs and every\nthread comment. It is not currently failing; it is simply the kind of thing that\nfails silently and only once someone notices.\n\nWHAT REOPENS IT:\n 1. The user creating the App -- the only real blocker.\n 2. Any instance of the convention actually being forgotten on a public\n artefact. One is enough: the failure mode is a human reading a model's\n words as his own, and it is not detectable after the fact by anything\n automated.\n 3. A second person gaining write access to the repo, which makes \"who said\n this\" ambiguous in a way the attribution line alone does not resolve.\n\nNOT A REASON TO REOPEN: more agents, or more PRs. Volume does not change the\nargument either way.\n\nNote the ticket's own closing paragraph still applies and should not be lost: when\nthis lands, the attribution doctrine is AMENDED rather than deleted, because a\nreader seeing an unattributed comment from the owner's account still needs to know\nit might be an agent.","status":"in_progress","priority":2,"issue_type":"task","assignee":"Ed","owner":"el-github@elangley.org","created_at":"2026-09-20T23:04:19Z","created_by":"orchestrator","updated_at":"2026-09-25T07:19:43Z","started_at":"2026-09-25T07:19:43Z","lease_expires_at":"2026-09-25T07:24:43Z","heartbeat_at":"2026-09-25T07:19:43Z","dependencies":[{"issue_id":"improvise-xfsb","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:51Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-84wp","title":"Doctrine: a check that looks like the answer, answering a neighbouring question","description":"Ready-to-apply prose for `context/design-principles.md` §7. Filed rather than committed because PR #60 head `ec03f95` is byte-identical (tree `ec5d531...`) to a tree the reviewer fully tested, and a further commit would destroy that property and require a fresh harness run while `main` was moving several times an hour. The artefact is worth more preserved than rushed.\n\n---\n\n### A check that looks like the answer, answering a neighbouring question\n\nThe characteristic defect in this repo is not a wrong line; it is a check that is *adjacent* to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:\n\n- **A vacuous assertion.** `set_panel_open_and_cursor` asserted `category_panel_open` was true after an effect set it true -- once the launch default supplied `true`, the effect could be replaced by a **no-op** and 1391 tests passed. It answered *is the flag true* where it meant *did the effect set it*.\n- **A vacuous repair of it.** The first fix folded `[open:false, open:true]` and asserted the final state -- which a reducer that folds nothing also satisfies. It answered *does the text mention the other direction* where it meant *does the writer move the flag both ways*. **Reading the diff is what failed**, and it failed because the vacuous version contained `open: false`.\n- **A clean auto-merge.** `git merge-tree` reporting no conflict answers *can git combine these texts*, where the question is *does the combination do what both sides meant*. Two PRs edited the same four files with no conflict and neither side CI-tested together.\n\nEach has the same remedy shape: **name the question you actually mean, then find an input that separates it from its neighbour.**\n\n- For an assertion: **assert the direction the default cannot supply.** Closing the panel discriminates; opening it proves nothing.\n- For a repair: inject a **one-directional** fault (`if open { set true }` -- can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the `true` direction is satisfied by a writer that only ever writes `true`.\n- For a merge: **materialise it and run the gates**, and check that a count which should MOVE actually moved. Playwright going 16 -\u003e 17 is what showed the other side survived; \"no conflict\" and an unchanged total would both have been consistent with losing it.\n\nThis generalises the existing `doctrine(fold_tests_must_test_order_not_the_axiom)` (asserting `fold(prefix)+fold(rest) == fold(whole)` is the definition of a left fold, so it answers a neighbouring question too). What the campaign adds is that the pattern recurs in **test repairs** and in **merge hygiene**, not only in production code and not only in fold tests.\n\n---\n\nAcceptance: the section is in §7; `doctrine(fold_tests_must_test_order_not_the_axiom)` cross-references it or is subsumed by it. Worth considering a `kb.pl` doctrine clause, which is orchestrator-only.\n","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T21:38:23Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qsmp","title":"App::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails","description":"`App::cmd_context` (crates/improvise-command/src/ui/app.rs) propagates `self.view_state.category_panel_open` into the `CmdContext`. **Hardcode it to `true` and the entire workspace passes** — 1394 tests, nothing fails.\n\nThat matters because `registry.rs` reads it in three places for `toggle-panel-and-focus`s open-vs-close decision and the panel focus/cycle commands. With it stuck true, pressing `C` on a CLOSED category panel decides to *close* it rather than open it — the panel becomes unopenable by its own key — and no test notices.\n\n**Why it is invisible:** `cmd/mod.rs`s `test_helpers` sets `category_panel_open` explicitly on the context it builds, so no command test ever exercises the real propagator. The tests construct the value they then assert on.\n\n**This is PRE-EXISTING and was not caused by the launch-default ruling (PR #60).** That distinction was established by control rather than assumed: with the default inverted, this mutation fails *exactly the same two tests as inverting the default alone* and nothing more. So it was unguarded before the ruling too. The remedy is therefore \"a test at all\", not the precondition-stating fixture that fixed the two genuinely-disarmed tests (`set_panel_open_and_cursor`, `panel_commands_address_the_named_panel_only`).\n\n**The method that found it is worth more than the finding** and is written up in `context/test-evidence/clients.md` under \"How to know a launch default has stopped disarming things\": mutate every site that writes, propagates or reads the value; sites where nothing fails are suspects; then re-run each suspect with the default inverted to separate *vacuous* (fix the fixture) from *unguarded* (file it). Grep cannot do this — it finds sites that NAME the flag, not tests that merely start from a default-built state.\n\nAcceptance: a command-layer test that drives the real `App::cmd_context` rather than a hand-built context, such that `toggle-panel-and-focus` on a closed panel opens it; proved by hardcoding the propagator and watching it fail.\n\nSibling note: `improvise-wasm-client/src/render.rs` has the same propagator shape for `RenderModel`. Hardcoding THAT one true is improvise-wsaw reintroduced one layer up, and Rust sees nothing — only the Chromium harness catches it, at `contract.spec.js`s `toBeHidden`. Consequence worth keeping: a Rust-only run of the `test` CI job cannot see a reintroduced wsaw, which is the sharpest argument on file for the browser job being able to fail the build.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T21:23:53Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:50Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:50Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5ray","title":"check-evidence ignores #### headings and every browser-test entry uses them","description":"Found by review of PR #65, 2026-09-20 (finding 4). Sibling of improvise-lyyc\nand improvise-r0qa, and the sharpest of the three because this one has already\nlet a false claim stand for a release.\n\nscripts/check-comment-runs.py --check-evidence collects headings with\n`s.startswith(\"### \")` and builds its `defined` set from rust_files(roots) only.\nTwo consequences compound:\n\n 1. A `####` heading is invisible to it — `\"### \"` does not match `\"#### \"`.\n 2. Even if it matched, `defined` holds only Rust `fn` names, so a JavaScript\n test name could never resolve.\n\ncontext/test-evidence/clients.md uses `#### \"quoted name\"` for every browser\ntest precisely BECAUSE of (2) — a `###` heading naming a Playwright test would\nbe reported as an orphan. So the convention that keeps the checker quiet is the\nsame convention that makes it blind.\n\nDEMONSTRATED: appending\n\n #### \"a test that does not exist anywhere\"\n\nto context/test-evidence/clients.md leaves `--check-evidence` exiting 0.\n\nWHAT IT COST: PR #61 deleted `a socket that opened and then dropped reads as\ndisconnected, and stays that way`. Its `####` heading stayed. The gate passed\nfor the whole release while the file promised a guard that was not there —\nexactly the \"dangling explanation\" design-principles §7 says the checker exists\nto prevent.\n\nFIX SHAPE: collect `###`/`####` alike, and extend `defined` with JS test names\n(`test(\"…\"` in harness/tests/*.spec.js and web/tests/*.test.js). Note the\nchecker's own --self-test must grow a case, or this fix is unguarded in exactly\nthe way the thing it fixes was.\n","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T21:22:55Z","created_by":"wave3-jstests","updated_at":"2026-09-20T23:35:11Z","started_at":"2026-09-20T23:22:05Z","closed_at":"2026-09-20T23:35:11Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -301,7 +379,7 @@ {"_type":"issue","id":"improvise-o31u","title":"The landing page's casts render at a 4.8px font on a phone, and it is now the site root","description":"Found in adversarial review of PR #47, measured rather than eyeballed, and\nreproduced independently before filing.\n\nOn a 390px viewport all four asciinema players on `docs/index.html` compute a\nterminal `font-size` of **4.83px**. The arithmetic is not subtle: the player is\ntold `cols: 120`, the container is ~356px wide inside the page's 16px gutters,\nand 356 / 120 gives about 3px per character, so the player scales the font down\nto 4.83px to fit. Desktop is **13.11px** and perfectly fine, so this is purely a\nnarrow-viewport effect and nothing about the current desktop rendering needs to\nchange.\n\nWHY THE PRIORITY IS HIGHER THAN IT LOOKS: until the user's ruling on\nimprovise-kh8 the landing page sat at `/landing/` and this was a page few people\nwould reach on a phone. It is now the SITE ROOT — the first thing a phone\nvisitor sees is four panels of unreadable grey mush above the call to action.\n\nPROPOSED FIX (the reviewer's, and it is the right shape): stop asking the player\nto fit 120 columns into the viewport and let it overflow instead —\n`overflow-x: auto` on `.player`, plus either `fit: false` or an explicit\n`terminalFontSize` in the `AsciinemaPlayer.create()` options, so the font is\nchosen rather than derived from the width.\n\nONE THING TO CHECK WHEN FIXING, because getting it wrong trades this bug for a\nworse one: the overflow must be contained INSIDE `.player`. A fix that lets the\npage itself scroll sideways breaks the whole document on a phone rather than one\nsection of it. The check is that `document.documentElement.scrollWidth` still\nequals `clientWidth` at 390px afterwards.\n\nNOTE A CANDIDATE that would close this for free: improvise-2oxt re-records the\ncasts. If they are re-recorded at a narrower geometry than 120 columns, the\narithmetic changes by itself — so whoever takes 2oxt should decide the geometry\nwith this ticket in front of them rather than after.\n\nA fix was written and measured during PR #47 (4.83px -\u003e 11.95px via a\n`max-width: 900px` media query giving `.player` `overflow-x: auto` and an 880px\nmin-width, page scrollWidth unchanged at 390) and then REVERTED on the user's\nruling that this should be filed rather than fixed in that PR. The measurements\nare evidence that the approach works; the code is not in the tree.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/` in a browser sized to 390x844 (or Playwright\n with `viewport: { width: 390, height: 844 }`).\n4. Press play on any of the four players and read the computed style of\n `.ap-terminal`: `font-size` is 4.83px. At 1280px wide it is 13.11px.","acceptance_criteria":"At a 390px viewport every player on docs/index.html renders its terminal at a legible font size (\u003e= 10px measured as the computed font-size of .ap-terminal), and document.documentElement.scrollWidth still equals clientWidth so the page itself does not scroll sideways. Desktop rendering at 1280px is unchanged. Verified by measuring both viewports against the assembled site, not against docs/ directly.","status":"closed","priority":2,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:22:32Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T04:43:19Z","started_at":"2026-09-21T03:28:14Z","closed_at":"2026-09-21T04:43:19Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jmjz","title":"grid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it","description":"Found by improvise-i5tk's benchmark (`cargo bench --bench grid_projection`), which\nwas run to decide a *different* question and answered this one instead.\n\nMEASURED, on this machine, one `project::grid_of` call = one TUI frame's grid cache:\n\n typical 100 x 4 = 400 cells whole-grid 1.902ms col_widths 45%\n large 1200 x 8 = 9600 cells whole-grid 185.644ms col_widths 64%\n 18k cells (gotcha 41) 3000 x 6 = 18000 cells whole-grid 402.435ms col_widths 42%\n pathological 9000 x 10 = 90000 cells whole-grid 5.235s col_widths 44%\n\nA 60fps frame is 16.7ms. At 18,000 cells -- the size gotcha 41 already treats as\nrealistic -- a TUI redraw takes about four tenths of a second, and the TUI redraws\non every keypress.\n\nWHY: the whole grid is evaluated roughly THREE TIMES per frame.\n\n1. `ui/viewport.rs::compute_col_widths` walks every (row, col) calling\n `layout.display_text`, to size the columns.\n2. The SAME function then walks every (row, col) AGAIN summing column totals, to\n size the total row.\n3. `project::grid_of` then evaluates the visible cells via `cell_display`, and\n `column_totals` sums every row again for each visible column.\n\nEvery one of those goes through `Model::evaluate_aggregated`, and every result is\nthrown away at the end of the frame.\n\nWHAT THIS IS NOT: it is not the viewport question. improvise-i5tk asked whether\nnarrowing the TUI's `GridCache` to its viewport would help, and the benchmark says\nit buys only 1.2-1.5x, because passes 1 and 2 are viewport-INDEPENDENT by\nconstruction -- `compute_col_widths` takes no viewport and a column total must sum\nevery row including scrolled-past ones (gotcha 44). So narrowing removes part of\none pass out of three. The fix here is caching or a single fused pass, not windowing.\n\n## Steps to Reproduce\n\n1. `cargo bench --bench grid_projection` on this branch.\n2. Read the `whole-grid` column: 402ms for 18,000 cells, against a 16.7ms frame.\n3. Read the `ratio` column: windowing the cache buys 1.5x, so the cost is not in\n the part a viewport would narrow.\n4. Read the `col_widths` column: one function, taking two whole-grid passes of its\n own, is 42-64% of the frame.","acceptance_criteria":"A frame's grid cache evaluates each cell at most once. The benchmark in benches/grid_projection.rs is the evidence both ways: it must show the whole-grid figure dropping by roughly the number of redundant passes removed, and it must still be run AFTER the change rather than the change argued from the structure. Note what the measurement already rules out: windowing the cache to a viewport is not the fix (1.2-1.5x measured, improvise-i5tk), because compute_col_widths takes no viewport and a column total must sum every row (gotcha 44). Do not lose the totals' whole-grid correctness while removing the whole-grid cost.","notes":"SECOND RUN 2026-09-20, same machine. The col_widths share is stable at 45-46%, not 42-64%: the single 64% in the first run's 'large' row was noise. whole-grid 1.9ms / 190ms / 327ms / 5.0s; ratio 1.2-1.5x throughout. The two figures the decision rests on -- ratio ~1.4x and col_widths ~half the frame -- reproduce; the per-row percentages do not to better than a few points. Quote ranges, not single runs.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:57Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-21T11:30:16Z","closed_at":"2026-09-21T11:30:16Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-zkkw","title":"The browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too","description":"Found walking improvise-bck's acceptance criteria in wave 2. Criterion (1) is\n\"all test plan steps pass in CHROME AND FIREFOX\", and bck's own grooming note\nrecords the standalone deployment as demonstrated by harness/. Both are true of\nChromium and neither is true of Firefox.\n\n`harness/playwright.config.js` declares three projects, and only the chromium\none has no `testMatch`. Firefox and WebKit both carry\n`testMatch: /opfs\\.spec\\.js/`, so `shell.spec.js` — every assertion about\nweb/app.js, including \"the page draws the demo document out of the worker\" and\n\"committing an edit sends one frame and shows the host's value\" — runs on\nChromium alone. The config's own comment says so (\"The shell spec is\nChromium-only\"), and the stated reason is that `routeWebSocket` is slower and\nflakier across three engines.\n\nTHAT REASON APPLIES TO FOUR OF THE SIX SPECS, NOT SIX. The four `?ws=` specs\nmock a peer with `routeWebSocket`. The two under \"the standalone deployment\"\ndo not: they load `/index.html` with no query and drive the in-page worker over\nOPFS — which is precisely the deployment bck's criterion is about, and\nprecisely the code Firefox exercises differently (its OPFS implementation is\nalready run by opfs.spec.js, so the engine is known to support the path).\n\nSo the cheap and honest change is to let the standalone describe block run on\nFirefox while leaving the routeWebSocket block Chromium-only. That is a\n`testMatch`/grep split rather than a third copy of the spec.\n\nWHAT MUST NOT HAPPEN: turning the whole file on for Firefox and then quieting\nwhatever flakes. The flakiness argument is about the mocked-socket specs and is\nrecorded; widening it to the standalone ones would be adopting a reason that\nwas never given for them.\n\n## Steps to Reproduce\n\n1. `grep -n testMatch harness/playwright.config.js` — firefox and webkit are\n both pinned to `opfs.spec.js`.\n2. `cd harness \u0026\u0026 npx playwright test --project=firefox --list` — no test from\n `shell.spec.js` is listed.\n3. improvise-bck criterion (1) nevertheless reads \"in Chrome and Firefox\".","acceptance_criteria":"harness/tests/shell.spec.js's 'the standalone deployment' describe block runs on the firefox project as well as chromium, and passes there in CI; the four routeWebSocket specs stay Chromium-only with the existing reason still written down. Verified by 'npx playwright test --project=firefox --list' naming the two standalone tests, and by a green browser job. improvise-bck criterion (1) is then re-checked against what actually runs.","notes":"PR #72. Tagged @mocked-peer; firefox testMatch widened with grepInvert. Measured 2.0s for the standalone pair, 10/10 under --repeat-each=5.","status":"closed","priority":2,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:02:30Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:06Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:06Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:epic"],"dependency_count":0,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"DIAGNOSED 2026-09-21 (PR #88). The cause is neither direction this ticket offered, and it is not concurrency in the sense the ticket meant.\n\nMEASURED over all 100 push-to-main CI runs 2026-09-19T22:02Z..2026-09-21T05:02Z, from the run/job/step/log APIs:\n\n 12/12 artifact-not-found deploy failures had ANOTHER run's SUCCESSFUL Pages\n deployment complete inside their own upload-\u003edeploy window\n 2/56 successes had the same\n 0/4 other deploy failures (those are the unrelated 'Create Pages site failed')\n\nDIRECTION IS SETTLED. The oldest run of the 17:47Z batch failed at 17:47:23Z, BEFORE the next deploy started (17:47:31Z) and before any Pages deployment existed at all, since deploy-pages fetches artifact metadata before creating one. It cannot have been caused by its own deploy, by supersession, or by a concurrent one. The artifact was already gone.\n\nTHE DECISIVE OBSERVATION, and it is scoped by artifact NAME rather than by run: run 35560726456 finalized harness-serve, wasm-bundles and github-pages seconds apart, each with an id; 2m21s later its own lookup reported 'Found 2 artifact(s)' with no github-pages. Same run, same token, same call, same retention. That single line rules out service flakiness, eventual consistency, retention, token scope and a v3/v4 backend mismatch simultaneously — any of them would have taken the siblings too.\n\nRULED OUT: queue depth (the three deploys never queued — 9s and 17s apart, each failure taking 1s); retention (all shared retention-days: 1, and the newest-finalized expires LAST, so expiry can yield 0/1/1 or 0/0/1 but never this ticket's 0/1/0); deletion by the action (an upstream feature request asks for that capability, open precisely because nothing does it); action-version drift (successes and failures alternate minute by minute on identically-resolved SHAs).\n\nONE CLAUSE OF THIS TICKET'S OWN CORRECTION IS FALSIFIED. It argued the tip-of-main guard would have skipped the publishing deploy 'and let 52507a6 deploy and fail anyway', assuming 52507a6's loss was independent of its neighbour. It was not: 5f871f7's deploy completed FOUR SECONDS before 52507a6's began. PR #46 aimed at the right quantity for the wrong reason.\n\nIT STILL MUST NOT SHIP. The association is not a mechanism, and the mechanism is not deterministic — 5f871f7 had a foreign deploy land inside its own window and survived. A guard measured at nothing, against a probabilistic effect, is doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix).\n\n## Acceptance Criteria (REPLACED — the old one asked for the guard this evidence does not support)\n- Either: no two runs hold a github-pages artifact across another run's Pages deployment.\n- Or: narrow the upload-\u003edeploy window and measure the failure rate against it.\n- Before any fix, reproduce deliberately: two pushes seconds apart on a scratch repo, polling /runs/{id}/artifacts every few seconds through both deploys, and watch the github-pages entry disappear at the moment the other deploy completes. Cheap, and it is what turns an association into a mechanism.\n\nFull argument: context/code/tooling.md §3. WHAT IS STILL UNKNOWN: why GitHub removes it. 12/12 vs 2/56 is an association; no upstream issue, changelog or doc describes the behaviour, and both the reaped and surviving artifacts are 404 now (retention-days: 1), so the state is gone.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-21T05:23:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-864a","title":"Concurrent merges to main race on one Pages environment and the superseded deploys fail","description":"DIAGNOSED 2026-09-21 (PR #88). The cause is neither direction this ticket offered, and it is not concurrency in the sense the ticket meant.\n\nMEASURED over all 100 push-to-main CI runs 2026-09-19T22:02Z..2026-09-21T05:02Z, from the run/job/step/log APIs:\n\n 12/12 artifact-not-found deploy failures had ANOTHER run's SUCCESSFUL Pages\n deployment complete inside their own upload-\u003edeploy window\n 2/56 successes had the same\n 0/4 other deploy failures (those are the unrelated 'Create Pages site failed')\n\nDIRECTION IS SETTLED. The oldest run of the 17:47Z batch failed at 17:47:23Z, BEFORE the next deploy started (17:47:31Z) and before any Pages deployment existed at all, since deploy-pages fetches artifact metadata before creating one. It cannot have been caused by its own deploy, by supersession, or by a concurrent one. The artifact was already gone.\n\nTHE DECISIVE OBSERVATION, and it is scoped by artifact NAME rather than by run: run 35560726456 finalized harness-serve, wasm-bundles and github-pages seconds apart, each with an id; 2m21s later its own lookup reported 'Found 2 artifact(s)' with no github-pages. Same run, same token, same call, same retention. That single line rules out service flakiness, eventual consistency, retention, token scope and a v3/v4 backend mismatch simultaneously — any of them would have taken the siblings too.\n\nRULED OUT: queue depth (the three deploys never queued — 9s and 17s apart, each failure taking 1s); retention (all shared retention-days: 1, and the newest-finalized expires LAST, so expiry can yield 0/1/1 or 0/0/1 but never this ticket's 0/1/0); deletion by the action (an upstream feature request asks for that capability, open precisely because nothing does it); action-version drift (successes and failures alternate minute by minute on identically-resolved SHAs).\n\nONE CLAUSE OF THIS TICKET'S OWN CORRECTION IS FALSIFIED. It argued the tip-of-main guard would have skipped the publishing deploy 'and let 52507a6 deploy and fail anyway', assuming 52507a6's loss was independent of its neighbour. It was not: 5f871f7's deploy completed FOUR SECONDS before 52507a6's began. PR #46 aimed at the right quantity for the wrong reason.\n\nIT STILL MUST NOT SHIP. The association is not a mechanism, and the mechanism is not deterministic — 5f871f7 had a foreign deploy land inside its own window and survived. A guard measured at nothing, against a probabilistic effect, is doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix).\n\n## Acceptance Criteria (REPLACED — the old one asked for the guard this evidence does not support)\n- Either: no two runs hold a github-pages artifact across another run's Pages deployment.\n- Or: narrow the upload-\u003edeploy window and measure the failure rate against it.\n- Before any fix, reproduce deliberately: two pushes seconds apart on a scratch repo, polling /runs/{id}/artifacts every few seconds through both deploys, and watch the github-pages entry disappear at the moment the other deploy completes. Cheap, and it is what turns an association into a mechanism.\n\nFull argument: context/code/tooling.md §3. WHAT IS STILL UNKNOWN: why GitHub removes it. 12/12 vs 2/56 is an association; no upstream issue, changelog or doc describes the behaviour, and both the reaped and surviving artifacts are 404 now (retention-days: 1), so the state is gone.","acceptance_criteria":"A run whose commit is no longer the tip of main skips actions/deploy-pages instead of failing; a burst of merges to main produces exactly one deploy attempt and no red X on the superseded runs.","notes":"CORRECTION 2026-09-20, from adversarial review of PR #46 — THE ORIGINAL DESCRIPTION'S PREMISE IS WRONG, and the fix built on it was closed unmerged.\n\nThe description says only the NEWEST run keeps its artifact. Re-checked against the API, it was the MIDDLE one:\n\n 04e4e3d (oldest) run 35526758561 artifacts=0 deploy=failure\n 5f871f7 (middle) run 35526767814 artifacts=1 deploy=SUCCESS\n 52507a6 (newest) run 35526774007 artifacts=0 deploy=failure \u003c- tip of main\n\nSo a 'skip unless this commit is the tip of main' guard would have skipped 5f871f7 — the one deploy that actually published — and let 52507a6 deploy and fail anyway. Pages never updates, main still red: strictly worse than the status quo. PR #46 closed on that evidence.\n\nThe guard also aimed at the wrong failure class: it reduces the NUMBER of deploys, while the observed error is a missing artifact within a run's OWN scope. Queue depth is not what makes an artifact survive.\n\nWHAT IS STILL UNKNOWN, and what any future fix must establish FIRST: why two of three artifacts were deleted when the upload step was green in all three. Note the surviving artifact belongs to the run whose deploy SUCCEEDED, which suggests the causation may run the other way (the deploys failed because their artifacts were already gone, not the reverse). Do not ship another guard until that is measured — the first one was built on a misread table and review caught it, which is the only reason it did not land.","status":"open","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:55:01Z","created_by":"orchestrator","updated_at":"2026-09-21T05:23:47Z","dependencies":[{"issue_id":"improvise-864a","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-fdt6","title":"A browser cannot add a formula, so FormulaEdit is a room that swallows typing","description":"Found while grooming improvise-bck after wave 1. It is the one acceptance criterion of the standalone web MVP that is not a verification gap but a MISSING FEATURE.\n\nimprovise-bck says: \"load a bundled demo .improv file, EDIT CELLS, ADD A FORMULA, save to OPFS, reload the page, verify persistence\". Everything in that sentence is now demonstrated by harness/ except adding a formula, because THE BROWSER HAS NO FORMULA ENTRY AT ALL.\n\nWHAT EXISTS: the formula PANEL has a projection (FormulaCache) and the browser receives it. What does not exist is any way to compose one — `commit-buffer formula` is in the hand-written list of text-entry commits the client cannot resolve (see improvise-e24v and the audit on improvise-g9jb), so FormulaEdit is a mode a browser can ENTER and not COMMIT. That is the dead-room shape doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key) names, and `strands_the_user` currently refuses SavePrompt and ExportPrompt but NOT FormulaEdit.\n\nSO THERE ARE TWO HONEST OUTCOMES AND THEY SHOULD BE DECIDED TOGETHER:\n 1. Implement `commit-buffer formula` client-side. All its operands are in ViewState (improvise-e24v lists it under \"needs nothing added to the protocol\"), so this is the cheap half — and then the browser can add a formula and improvise-bck's criterion is met as written.\n 2. Or refuse FormulaEdit in `strands_the_user` until (1) lands, so the browser stops offering a room that swallows typing — and improvise-bck's criterion is amended to say formulas are a TUI-only affordance for now.\n\nDOING NEITHER IS THE CURRENT STATE AND IT IS THE WORST OF THE THREE: the mode opens, the user types a formula, and nothing happens.\n\nNOTE FOR WHOEVER TAKES IT: the emitted formula must go through the same add_formula path as a hand-typed one, so _Measure target handling stays identical (gotcha 7) — the same constraint improvise-vx3 worked under. And parse_formula strips pipes off a target (gotcha 34), so the client must not pre-quote.\n\n## Steps to Reproduce\n\n1. Open the document in the browser and press `F` (or `:enter-mode formula-edit`).\n2. The mode opens — `enter-mode` resolves locally in the wasm client.\n3. Type a formula and press Enter. `commit-buffer formula` has no arm in `keys.rs::step`, so `resolve` returns `Upstream`, and `Host::handle` answers `ClientFrame::Key` with an explicit empty vec.\n4. Nothing happens. The typing is gone and no error is shown.\n\n`strands_the_user` refuses `SavePrompt` and `ExportPrompt` for exactly this shape and does not refuse `FormulaEdit`.\n","acceptance_criteria":"Either commit-buffer formula resolves client-side and a browser can add a formula end to end (demonstrated in harness/), or FormulaEdit is refused by strands_the_user with improvise-bck's criterion amended to say formulas are TUI-only for now. The current state — enterable, uncommittable, silent — is not an acceptable outcome. Whichever is chosen, the audit list in improvise-g9jb and the named gap list in keys.rs move with it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:55:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:15Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-fdt6","depends_on_id":"improvise-e24v","type":"relates-to","created_at":"2026-09-20T07:55:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-8qlr","title":"Column-group run coalescing is derived twice, and the agreement test does not pin the TUI arm","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\nColumn-group RUN COALESCING is derived in two places:\n * src/ui/grid.rs:189-192, coalescing on `group_name != prev_group`\n * crates/improvise-wasm-client/src/render.rs:339-364 (`col_groups`), coalescing by `label` alone — with a comment saying \"which is what src/ui/grid.rs does ... and the two must agree\"\n\nA comment is what keeps them in step. AxisFrame already owns `groups()`; a `group_runs()` there would make this one rule instead of two.\n\n## Steps to Reproduce\n\n1. In src/ui/grid.rs, force the TUI to emit the group label on EVERY column (`match (true, col_group)`) — a visibly different grid.\n2. `cargo test -p improvise` stays GREEN.\n\n`tests/grid_frame_agreement.rs::the_two_front_ends_draw_the_same_column_header` compares the BROWSER's spans against a hard-coded `vec![(Some(\"Q1\"), 6)]` but only checks the TUI's group line with `group_line.contains(\"▼ Q1\")`. That `contains` is the unpinned arm.\n\n## Note\n\nThe rest of that test file is exemplary — it drives each front end the way it is really driven and records its injected-fault evidence. This is its one weak assertion, and it is weak in the direction that matters: the side the migration could most easily have broken.\n","acceptance_criteria":"The run rule lives once, on AxisFrame (a group_runs() beside groups()), and both front ends call it. the_two_front_ends_draw_the_same_column_header compares the TUI's spans EXACTLY rather than with contains(), so forcing the TUI to emit a group label on every column fails it — verify by injecting that fault.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8uj8","title":"every_rejection_message_says_the_edit_did_not_land covers five of seven variants","description":"Found by the post-wave code consistency review, CONFIRMED BY INJECTION.\n\ncrates/improvise-wasm-client/src/socket.rs:273-288. The test is named `every_rejection_message_says_the_edit_did_not_land` and its doc says the fact \"is asserted over all of them rather than one at a time\". It drives a HAND-WRITTEN LIST of Rejection variants, and that list predates PR #26, which added `NoDocumentPath` and `SaveFailed`.\n\n## Steps to Reproduce\n\n1. Add `NoDocumentPath` and `SaveFailed` to the test's list.\n2. It FAILS: \"... which does not say the edit did not land\".\n\nSo two variants were silently exempted from a rule the test claims is universal, rather than argued out of it.\n\n## The nuance that makes this a re-scope rather than a message fix\n\nThe messages themselves are defensible — a save failure did not fail to APPLY anything, so \"the edit did not land\" is the wrong sentence for it. But then the TEST'S CLAIM is what is wrong, and a test asserting something false about its own subject is worse than no test.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) names this exactly.\n","acceptance_criteria":"The test is driven by an EXHAUSTIVE match over Rejection so a new variant cannot slip, and its claim matches what it checks — either every variant really does say the edit did not land, or the test is re-scoped to the subset for which that sentence is correct and says which and why. Adding a variant to Rejection fails the build until someone answers for it.","status":"closed","priority":2,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:16Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -312,7 +390,7 @@ {"_type":"issue","id":"improvise-sihr","title":"Every minibuffer mode entered from records mode exits to plain Normal while the layout is still records","description":"Found by Copilot on PR #27 against the new Ctrl-W save prompt, and DECLINED there with evidence because fixing the one arm the finding named would have been one arm of a three-arm rule.\n\nTHE BEHAVIOUR: every minibuffer mode entered from RecordsNormal exits to plain Normal while the LAYOUT IS STILL RECORDS. It affects `:` (CommandMode), Ctrl-E (ExportPrompt) and Ctrl-W (SavePrompt, new in PR #27). Only the last is new; the class predates it.\n\nTHE MECHANISM: the exit mode is a CONSTANT, baked into TextEntry::new(.., \"normal\") in the keymap builder and into each commit command's ChangeMode. Nothing carries where the user came FROM, so nothing can put them back.\n\nWHAT IS NOT THE MECHANISM, checked rather than reasoned (doctrine(binding_dump_diff)): the binding TABLE is fine. RecordsNormal goes 155 -\u003e 156 dumped lines across PR #27, the single addition being Char('w')+CONTROL -\u003e enter-mode [\"save-prompt\"], with nothing removed. with_parent inheritance (gotcha 13) is intact. What is lost is the RUNTIME MODE, not a binding.\n\nCHARACTERIZED, NOT FIXED: every_minibuffer_mode_entered_from_records_returns_to_plain_normal drives all three and asserts both halves -- the mode is Normal, and the layout is still records. So the current behaviour is pinned and a fix will have to update it deliberately.\n\nTHE FIX, and it is why this is its own bead: text-entry modes must carry their entry mode. That is a change to TextEntry, to AppMode (the exit is part of a mode's configuration, so it likely belongs beside MinibufferConfig -- note gotcha 11, MinibufferConfig::buffer_key is a String and not \u0026'static str precisely because ViewState must stay Deserialize-able, and the same constraint applies to anything added there), and to three commit commands. Wider than the PR that found it.\n\nWATCH TWO THINGS:\n * The exit is currently spelled in TWO places per mode -- the TextEntry spine's Esc binding and the commit command's ChangeMode. If they are made to carry an entry mode, make them read ONE source or the two will disagree on cancel versus commit.\n * SearchMode is hand-written on purpose and has no commit key (gotcha 11). It is not part of this class; do not sweep it in.\n\nRELATED: improvise-mai9 (a status message written while a minibuffer is open is never drawn) is the other half of why these modes are awkward -- both are consequences of the minibuffer owning the bottom row and nothing tracking what it displaced.\n\n## Steps to Reproduce\n\n1. Enter records mode (`R`).\n2. Press `:`, or `Ctrl-E`, or `Ctrl-W` — any minibuffer mode.\n3. Commit or cancel.\n4. The app is now in plain `Normal` while the LAYOUT is still records: the exit mode is a constant baked into `TextEntry::new(.., \"normal\")` and into each commit command's `ChangeMode`, and nothing carries where the user came from.\n\n`every_minibuffer_mode_entered_from_records_returns_to_plain_normal` drives all three and asserts both halves.\n","acceptance_criteria":"A minibuffer mode entered from RecordsNormal returns to RecordsNormal. Text-entry modes carry their entry mode rather than a constant 'normal'. The exit is currently spelled TWICE per mode — the TextEntry spine's Esc binding and the commit command's ChangeMode — so both read ONE source or they will disagree on cancel versus commit. SearchMode is hand-written on purpose and is not part of this class. every_minibuffer_mode_entered_from_records_returns_to_plain_normal is updated deliberately, not deleted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:12:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T17:09:48Z","closed_at":"2026-09-21T17:09:48Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-sihr","depends_on_id":"improvise-mai9","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kc7n","title":"improvise-ws-server never checks Origin, so any page a user visits can drive their session","description":"Found by Copilot on PR #21 round 5, against a reverse-proxy recipe whose IP allow-list had itself been added two rounds earlier to close a DIFFERENT hole. Verified independently by the orchestrator: `grep -rni origin crates/improvise-ws-server/src/` returns NOTHING. The server never inspects the Origin header and accepts every handshake.\n\nTHE HOLE: a WebSocket is EXEMPT FROM THE SAME-ORIGIN POLICY -- there is no CORS preflight on a handshake. So any hostile page that an allowed user visits can open a socket to the endpoint FROM THAT USER'S OWN ALLOWED ADDRESS, and read and edit every document the server holds. That is cross-site WebSocket hijacking.\n\nWHY NETWORK CONTROLS CANNOT SEE IT: an IP allow-list, a private network, loopback binding -- all answer \"may this CLIENT reach the endpoint\", and in this attack the answer is genuinely yes. The request really does come from an allowed client; it is simply being DRIVEN by a page the user did not intend to trust. Only Origin separates the two.\n\nLOOPBACK IS NOT EXEMPT, and this is the half a documentation-only fix cannot reach. In the local pairing case there is no proxy at all, so there is nothing in front to check Origin -- and loopback is not a boundary a browser enforces. While the server runs, ANY page open in that browser can reach ws://127.0.0.1:8787. So the local development path, which is the one the project actually recommends, is the least protected.\n\nDISTINCT FROM improvise-kpnr, and both are needed. kpnr is AUTHENTICATION: who is allowed to talk to this server at all. This is CSWSH: given that someone is allowed, what may put words in their mouth. An answer to either leaves the other open.\n\nTHE FIX IS CHEAP AND SERVER-SIDE: an allow-list of permitted origins checked at the handshake, defaulting to something safe for the local case (the page's own origin, and whatever --bind implies), with a flag to widen it. Refuse the upgrade rather than accepting and ignoring.\n\nTWO THINGS TO GET RIGHT:\n * A MISSING Origin header is not the same as a disallowed one. Non-browser clients (the repo's own tests, curl, a native client) send none, and the browser always does. Decide explicitly what absent means rather than letting the match arm decide by accident.\n * PR #21 has already documented Origin filtering in the PROXY as a required companion to the network control, phrased as \"in addition to, never instead of\". When this lands, the README should say the server does it too, and the proxy line becomes defence in depth rather than the only defence.\n\nTHE GENERAL LESSON, now doctrine(who_may_connect_is_two_questions): ask of any access control whether it constrains the CLIENT or what may DRIVE the client. Three consecutive rounds of that PR reasoned about the page's capabilities and none about the attacker's.\n\n## Steps to Reproduce\n\n1. Run `improvise-ws-server` on any document.\n2. From a browser that is allowed to reach it — the user's own machine, or any address an IP allow-list permits — visit an unrelated page that opens `new WebSocket(\"ws://127.0.0.1:8787\")`.\n3. The handshake succeeds. A WebSocket is exempt from the same-origin policy, so there is no CORS preflight and `grep -rni origin crates/improvise-ws-server/src/` returns nothing — the server never looks.\n4. That page can now read and edit every document the server holds, from the user's own allowed address.\n\nLoopback is not exempt and has no proxy in front of it, so the local development path is the least protected.\n","acceptance_criteria":"improvise-ws-server refuses a handshake whose Origin is not allow-listed, defaulting to something safe for the local case with a flag to widen it. A MISSING Origin is decided explicitly rather than by accident — non-browser clients (the repo's own tests, curl, a native client) send none and browsers always do. The README's proxy-side Origin filtering becomes defence in depth rather than the only defence.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:09:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:33:43Z","closed_at":"2026-09-21T04:33:43Z","close_reason":"Closed","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kc7n","depends_on_id":"improvise-kpnr","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mai9","title":"A status message written while a minibuffer is open is never drawn","description":"Found by WU-G while building improvise-lae (PR #27), and CONFIRMED THE HARD WAY in the same PR: Copilot then found a regression that this mechanism causes, which the agent had itself written down an hour earlier and not checked its own change against.\n\nTHE MECHANISM: src/draw.rs::draw_bottom_bar renders the minibuffer INSTEAD OF the status line. So a status_msg written while a minibuffer is open is never drawn at all -- not truncated, not overwritten, simply never rendered.\n\nWHO IS AFFECTED TODAY:\n * CategoryAdd / ItemAdd: a failed `commit-buffer category` is INVISIBLE. Worse, improvise-6t3 deliberately ordered that command's effects so the add effect is emitted LAST, specifically so its failure message would WIN over the command's optimistic status. It wins a race to a surface nobody can see.\n * A failed `:w \u003cpath\u003e` from CommandMode, which is the regression Copilot found on PR #27: the abort stops the batch before ReturnToNormalUnlessMoved and clear-buffer run, so the app stays in CommandMode with the stale command in the minibuffer and the new error hidden behind it.\n\nTHE WORKAROUND ALREADY SHIPPED, and it is a workaround: CommitSave emits [ChangeMode(Normal), SaveAs] so the error lands when the bottom row is the status bar again. That works for one command and does not generalise -- every other text-entry commit has the same hole.\n\nTWO CANDIDATE FIXES, and the choice is the ticket:\n * Draw both: give the minibuffer and the status line separate rows, or render the status into the minibuffer row when a message is pending. Cheap to describe, and it changes frame geometry, which is pinned by exact-frame characterization tests (see improvise-q0vb for what that costs).\n * Make it unrepresentable: a status message set while a minibuffer is open is a bug in the CALLER, so make the caller unable to do it -- e.g. commit paths always leave the mode before reporting. That is what CommitSave now does by hand, promoted to a rule.\n\nPREFER WHICHEVER MAKES THE FAILURE LOUD. The current state is the quiet kind: the code sets a message, the test asserts the message was set, and the user never sees it. A test asserting `app.status_msg == \"...\"` passes in every one of these cases, which is why this survived improvise-6t3's careful effect ordering.\n\n## Steps to Reproduce\n\n1. Enter a text-entry mode that opens a minibuffer — `CategoryAdd` (`N`) is the clearest.\n2. Commit something that fails, so the command sets a status message.\n3. Nothing appears: `src/draw.rs::draw_bottom_bar` renders the minibuffer INSTEAD OF the status line, so the message is never drawn at all — not truncated, not overwritten.\n4. A test asserting `app.status_msg == \"...\"` passes, which is why this survived improvise-6t3's careful effect ordering.\n\nSecond path, found on PR #27: a failed `:w /bad/path` aborts before `ReturnToNormalUnlessMoved`, leaving the app in `CommandMode` with the stale command drawn and the error hidden behind it.\n","acceptance_criteria":"A status message set while a minibuffer is open reaches the user. Either the two share the bottom row, or a commit path is structurally unable to report while its minibuffer is open (which is what CommitSave now does by hand, promoted to a rule). Whichever is chosen, the failure becomes LOUD: a test asserting app.status_msg was set must no longer pass while the user sees nothing.","status":"closed","priority":2,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-mai9","depends_on_id":"improvise-sihr","type":"relates-to","created_at":"2026-09-20T06:12:22Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kpnr","title":"improvise-ws-server has no authentication, and the documented sharing path now exposes it","description":"Found by Copilot on PR #21 (improvise-o8fa, the reverse-proxy documentation), and it is a finding ABOUT the documentation that PR introduced rather than about code.\n\nimprovise-ws-server has NO AUTHENTICATION BY DESIGN. crates/improvise-ws-server/src/transport.rs says it outright: anyone who can open a socket to the port can rewrite every document it serves. The only control today is that it binds loopback.\n\nTHE README NOW TELLS USERS TO PUT A PUBLIC TLS PROXY IN FRONT OF IT (improvise-o8fa, per the user's ruling that the reverse proxy is the supported path to reaching a TUI-hosted document from an https origin). That instruction REMOVES THE ONLY CONTROL. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now LOAD-BEARING DOCUMENTATION rather than a server feature. PR #21 mitigated it -- the warning leads the section, the Caddyfile carries basic_auth marked not-optional, the --bind note now separates what loopback buys from what it does not, and the tunnel line says cloudflared/ngrok print public URLs and authenticate nothing -- but mitigation in prose is not the same as a property of the system.\n\nTHE DECISION TO MAKE, and it is a real fork rather than an oversight to correct:\n * The server grows a shared secret or token, checked at the websocket handshake. Small, and it makes the documented path safe by default rather than safe if followed.\n * Or \"auth is the proxy's job\" is the PERMANENT answer, stated in the server's own docs as a design position rather than left as an absence. Defensible -- it is what most single-binary dev servers do -- but then the README recipe is the security boundary and should say so in those words.\n\nDO NOT SPLIT THE DIFFERENCE. A token that is optional and off by default is the worst of both: it reads as a feature and defends nothing.\n\nNOTE THE GENERALISABLE PART, worth keeping whichever way this goes: DOCUMENTATION THAT INSTRUCTS AN ACTION CARRIES THAT ACTION'S CONSEQUENCES. The PR #21 section was scrupulous about one axis (build complexity, the cost of a second binary to a one-binary tool) and blind on another (exposure), and the --bind paragraph actively REASSURED the reader -- \"not reachable from anywhere else\", true of the port and false of the deployment. Neither the ticket, nor gotcha 48, nor the agent's own /code-review pass caught it; a reviewer reading the server's source did.\n\n## Rationale\n\nimprovise-ws-server has no authentication by design and `transport.rs` says so outright: anyone who can open a socket to the port can rewrite every document it serves. The only control was loopback binding — and improvise-o8fa's README now instructs users to put a public TLS proxy in front of it, which REMOVES exactly that control. TLS is confidentiality, not access control, and in a copy-pasteable recipe the two read alike.\n\nSo proxy-side auth is now load-bearing documentation rather than a property of the system. PR #21 mitigated it in prose; mitigation in prose is not the same as a mechanism.\n\n## Alternatives Considered\n\n**A shared secret or token checked at the handshake.** Small, and it makes the documented path safe by default rather than safe-if-followed. Note a URL query parameter is the one credential `new WebSocket(url)` can carry — the API sets no headers.\n\n**\"Auth is the proxy's job\" as a stated permanent position**, written into the server's own docs as a design decision rather than left as an absence. Defensible — it is what most single-binary dev servers do — but then the README recipe IS the security boundary and should say so in those words.\n\n**Rejected: a token that is optional and off by default.** The worst of both: it reads as a feature and defends nothing.\n","status":"open","priority":2,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:45:28Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:40Z","labels":["area:browser"],"dependencies":[{"issue_id":"improvise-kpnr","depends_on_id":"improvise-kc7n","type":"relates-to","created_at":"2026-09-20T06:09:49Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kpnr","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.3","title":"Projection freshness for a source an external writer can change","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nADR 001 governs concurrent EDITS. A live-backed model is read-only, so it has none and the basis check does not apply to it -- the guarantee is not voided, it is scoped to documents improvise owns, which is what it always meant.\n\nWHAT REMAINS IS A DIFFERENT AND SMALLER QUESTION, and this bead is it: an EXTERNAL WRITER changes what clients SEE. So a live-backed projection needs a freshness story -- polled, triggered, or explicitly stale-until-refreshed. That is projection freshness, not conflict resolution, and conflating the two is the trap.\n\nNOTE WHAT CHANGES ABOUT THE PROJECTION ARCHITECTURE: today's diff (ui/project.rs::grid_updates) compares a desired state derived from the App against each client's mirror. With a live source, \"the desired state\" involves latency -- a HashMap lookup becomes a query -- so the VIEWPORT-BOUNDED PROJECTION STOPS BEING AN OPTIMISATION AND BECOMES LOAD-BEARING. improvise-i5tk (narrow the TUI's GridCache to its viewport, benchmark first) is the related bead and its answer may be forced by this one.\n\nSHARED WITH THE SQLITE SOURCE CHILD: the RecordHash -\u003e record index becomes a cache needing invalidation rather than a structure maintained alongside writes. One invalidation story, not two.\n\nEXPLICITLY OUT OF SCOPE: anything resembling a lease, a lock, or a reservation. Those are ADR 001 layer 2 and they are about edits.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:21Z","dependencies":[{"issue_id":"improvise-rucf.3","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.2","title":"SQLite source implementation: aggregate pushdown over a user-supplied query, native-only and feature-gated","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent.\n\nThe SQLite implementation of the source trait: aggregate pushdown against a user-supplied query.\n\nWHICH ROWS: a user-supplied SELECT, with a bare table name as sugar over it. A query is strictly more useful (joins, filters, computed columns) and no harder to consume -- a result set is a result set.\n\nTYPE MAPPING, and it is BETTER INPUT THAN CSV rather than merely different: a SQLite column carries a declared type, where import/analyzer.rs::detect_date_format currently guesses a date by pattern-matching text. INTEGER/REAL are measure candidates, TEXT a category candidate, BLOB skip. NULL is ABSENCE, not the string \"null\" (improvise-ych). Dates may be TEXT, INTEGER (epoch) or REAL (julian) -- DETECT, DO NOT ASSUME. SQLite is dynamically typed per value, so both the declared-type and the sampled path are real and the classifier should take typed input where it has it.\n\nWHERE THE BOUNDARY GOES: forbidden(improvise_core, io). The source TRAIT belongs in improvise-core; this implementation belongs in improvise-io, FEATURE-GATED AND NATIVE-ONLY. libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper, and improvise-io is in the wasm bundle, so the wasm CI job must keep passing untouched. VERIFY THE GATE, do not assume it -- the improvise-9ix agent established the wasm failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating protects the browser bundle rather than working around an impossibility.\n\nNO WRITES, and not as a runtime refusal: per ADR 003 writes are not in the trait a query source implements, and a source projecting no CellId::Record makes a client write unconstructible.\n\nINTERNING AND IDENTITY: DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work -- coordinates come from the query -- but the RecordHash -\u003e record index that resolves a CellId becomes A CACHE WITH AN INVALIDATION STORY rather than a structure maintained alongside writes. That invalidation is shared with the freshness child; do not solve it twice.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:21Z","dependencies":[{"issue_id":"improvise-rucf.2","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rucf.1","title":"The .improv source specification: a live-backed document is a saved query plus a pivot configuration","description":"Child of improvise-rucf (ADR 003, live-backed models). Filed and DEFERRED 2026-09-19 alongside its parent; the direction is accepted but not a priority.\n\nA live-backed document splits into OUR configuration and THEIR data, and .improv does not currently distinguish them. Today a file holds categories, views, formulas AND a ## Data section. A live-backed model's categories and cell values belong to the source; the views, formulas and axis assignments belong to improvise.\n\nSo the format grows a SOURCE SPECIFICATION where a live model would otherwise have data: a connection, a query, and the column-to-field mapping. Categories become DERIVED (SELECT DISTINCT) rather than declared.\n\nWHY THIS IS THE FIRST CHILD: persistence/improv.pest is the single source of truth (design-principles 3) and the parser is a tree-walker over it, so the grammar changes before the walker. The grammar-walking test generator reads the grammar AST via pest_meta, so a new production needs generator coverage or the round-trip proptests silently stop describing the format.\n\nTHE FRAMING THAT MAKES THIS FEEL RIGHT RATHER THAN LIKE A COMPLICATION, from the ADR: a live-backed .improv is A SAVED QUERY PLUS A PIVOT CONFIGURATION -- which is exactly what a saved report is in any BI tool. The document stops being \"my data\" and becomes \"my question\", portable between databases in a way a data file never is.\n\nOPEN: whether a document may be PARTLY live (some categories declared, some derived) or whether live and owned are exclusive per document. Exclusive is almost certainly right and much cheaper; say so deliberately rather than leaving it implied.\n","status":"open","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:24:20Z","dependencies":[{"issue_id":"improvise-rucf.1","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-20T05:31:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -334,7 +412,7 @@ {"_type":"issue","id":"improvise-l4qx","title":"[bug] A text cell is invisible whenever any category is on the None axis","description":"Found during improvise-q08 (2026-09-19), PRE-EXISTING, and it is the NORMAL case rather than an edge one.\n\nModel::evaluate_aggregated takes the aggregating path when none_cats is non-empty, and that path does filter_map(as_f64) -- so a CellValue::Text aggregates to NOTHING and never becomes a CellDisplay. _Index and _Dim sit on Axis::None in a default view, so none_cats is non-empty almost always.\n\nWHY IT MATTERS: .improv has pipe-quoted text cells as a FIRST-CLASS FEATURE -- the format doc gives Region=West, Measure=Revenue = |pending| as an example -- and they do not render. A user can write a text value, save it, load it and not see it.\n\nThe q08 agent hit this while building a fixture, worked around it there, and noted it in the round-trip test comment. Costing a test iteration is how it surfaced; nothing in the suite covers it.\n\nWrite the failing test first: a text cell in a default view (so _Index/_Dim are on None) must render its text. Then decide what aggregation of text should MEAN -- filter_map(as_f64) silently dropping it is clearly wrong, but \"show the single value when there is exactly one, and something explicit when there are several\" is a product decision, not an obvious fix. Note gotcha 7: apply_agg and eval_agg already differ on empty input, so this family has precedent for being decided rather than defaulted.","status":"closed","priority":2,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:46:09Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:24:05Z","closed_at":"2026-09-20T00:24:05Z","close_reason":"FIXED. aggregate_cell_values in model/types.rs, shared by evaluate_aggregated and aggregate_raw (which had byte-identical bodies precisely so they could not drift -- improvise-kdp).\n\nTHE RULE IS UNANIMITY, and the ORDER is load-bearing:\n empty -\u003e None (absent, unchanged -- follows apply_agg)\n ALL NUMERIC -\u003e apply_agg -\u003e Number (unchanged; checked FIRST)\n all values equal -\u003e that value\n otherwise -\u003e Error(\"mixed\")\n\nReducing several copies of one value is the identity, so that reduction has an answer even when the values are not numbers -- which covers the case the ticket is actually about. Two different words, or a word among numbers, have no reduction, and gotcha 7 already has precedent for saying so out loud (eval_agg returns Err(\"empty\") for AVG/MIN/MAX over nothing rather than inventing a value). So this is consistent with BOTH existing rules: emptiness follows apply_agg (absent, not zero), undefined-reduction follows eval_agg (explicit error, not silence).\n\nTHE ORDERING IS THE WHOLE DIFFERENCE from the ticket own first suggestion. \"Show the single value when exactly one matches\" taken literally BREAKS Count, whose entire point is to not be the value. The all-numeric branch is tested first, so Count of a lone number is still 1. Proven by injection: swapping the branch order failed exactly one test, a_lone_number_still_counts_as_one_rather_than_passing_through, which is why that guard exists.\n\nREJECTED: \"show the first\" (depends on DataStore hash iteration order, so the displayed value would be arbitrary AND look authoritative while hiding the others -- worse than the blank); \"show a count\" (changes the type of the answer; a count rendered under a Sum measure is indistinguishable from real data); and making the display path differ from the formula path.\n\nBEHAVIOUR CHANGE TO KNOW: a formula ref over MIXED values now errors \"mixed\" instead of \"ref:name\". Still an error, but a truer one, and it no longer silently sums the numbers while dropping the text. Text-valued refs still error exactly as before. Fixed-point convergence untouched.\n\nFailing test first: a_text_cell_renders_in_a_default_view, which also asserts !none_cats.is_empty() to confirm the premise that a DEFAULT view really does put _Index/_Dim on None -- the normal path, not an edge one. 1024 tests pass; three faults injected, all fired.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vjms","title":"Decide whether a category panel shows the virtual categories","description":"Found by improvise-35e (2026-09-19). A GENUINE BEHAVIOUR DIVERGENCE, which is why that ticket stopped rather than migrating the widget.\n\nThe TUI category panel builds from cat_tree::build_cat_tree, which walks Model::category_names() and therefore draws _Index, _Dim and _Measure. CategoryRow/category_rows carry only regular_category_names(), and CategoryTreeCache own doc says \"Only the categories a user may see -- the client does not know the _-prefixed virtual-category convention\".\n\nSo migrating the category panel to the cache would SILENTLY DROP THREE ROWS from the TUI panel. That is a behaviour change, not a relocation, so doctrine(characterization_survives_the_move) says stop and ask.\n\nTHE DECISION IS improvise-edp: either the browser panel grows the virtual categories, or the TUI panel loses them. Note gotcha 7 -- _Measure items are explicit items UNION formula targets, so a client showing _Measure needs measure_item_names semantics, not a raw item list; that is part of the cost of the first option.\n\nWhichever way it goes, the widget then also needs a tree flattener over CategoryTreeCache -- today build_cat_tree takes \u0026Model.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:06:54Z","closed_at":"2026-09-20T00:06:54Z","close_reason":"DECIDED (user, 2026-09-19): the TUI keeps the virtual categories AND the browser gets them too. CategoryTreeCache grows to carry _Index, _Dim and _Measure, so both panels render the same thing and the cache stays the single source for what a category panel shows.\n\nWHAT THIS COSTS, and it is the part to get right: the client now has to be told about the _-prefixed convention, which the caches doc currently says it does not know. In particular _MEASURE ITEMS ARE DERIVED, not a raw list -- gotcha 7: they are the explicit items UNION the formula targets, and add_formula does NOT add items to _Measure. So category_tree_of must ship membership computed through measure_item_names()/effective_item_names(), never category(\"_Measure\").items, or the panel will silently omit every formula target. That is the same trap improvise-ete was closed for proposing.\n\nConsequences to carry into improvise-edp:\n * CategoryTreeCache and its doc comment change -- the \"the client does not know the _-prefixed virtual-category convention\" line is now false and must go.\n * build_cat_tree can then migrate off \u0026Model, which is what unblocks the category panel and therefore edp.\n * A characterization test should pin that the cache carries all seven rows for a default workbook (4 regular + 3 virtual) and that _Measure includes a formula target that is not an explicit item -- one fixture that would fail under the raw-list implementation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pauh","title":"Make the beads flake pin actually bind in agent worktrees","description":"Two mitigations, BOTH wanted (user, 2026-09-19). Manual direnv allow is not sufficient on its own -- see the scaling problem below.\n\nTHE PROBLEM. flake.nix pins beads to github:gastownhall/beads/v1.3.0 precisely to control bd version. A git worktree does NOT load that pin: .envrc is unallowed in a fresh worktree, so a process there falls through to ~/.nix-profile/bin/bd. Verified 2026-09-19 with a scrubbed DIRENV env: \"Found RC allowed 0\". Today the profile bd is ALSO 1.3.0, so nothing breaks -- which is exactly why a careless test passes and why this is worth fixing before it bites.\n\nTHE FAILURE MODE IS SILENT. See the bd-1-0-2-dev-embedded-dolt-dependencies memory: bd VERSION SKEW SILENTLY EMPTIES THE DEPENDENCY GRAPH in .beads/issues.jsonl. No error; the loss is invisible until someone asks what blocks what. The flake pin exists to prevent this and an unallowed worktree bypasses it.\n\nWHY MANUAL ALLOW DOES NOT SUFFICE: agent worktrees are created FRESH PER DISPATCH. Allowing the ones that exist right now does not help the next one, so any manual pass is stale the moment another agent starts. Confirmed today -- allow entries were created at 16:35 and a worktree from earlier still reported allowed 0.\n\nMITIGATION 1 -- make the allow automatic. direnv allow (or an equivalent: a whitelist.prefix entry in ~/.config/direnv/direnv.toml covering the worktree root, which would cover every future worktree in one stroke and is probably the better answer) at worktree creation. Note whitelist.prefix is currently [] per direnv status.\n\nMITIGATION 2 -- assert the version rather than trust it. Have .claude/hooks/session-start.sh compare bd version against the flake pin and FAIL LOUDLY on a mismatch. doctrine(force_the_failure) applies: the whole point is that the skew is silent, so the check must be the thing that is not. Read the pin from flake.nix rather than hard-coding it, or the assertion rots the first time the pin moves.\n\nReading with a mismatched bd is safe; it is MUTATING commands that corrupt. If a cheap distinction is available, guard those specifically.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:39:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:30:40Z","closed_at":"2026-09-20T00:30:40Z","close_reason":"Half 2 landed: section 0 of .claude/hooks/session-start.sh asserts bd matches the flake pin, reading the pinned version OUT OF flake.nix so there is no second copy to rot on the next bump. Fails loudly (exit 2 + banner) on mismatch, on an unparseable pin, AND on an unreadable bd version -- an assertion that cannot answer is not a pass. Placed BEFORE the CLAUDE_CODE_REMOTE gate, because the local worktree is where the problem lives; the cloud path re-checks after installing bd and still completes tracker sync and cargo warm. It blocks nothing -- reads are safe, and the banner names the mutating commands specifically.\n\nDemonstrated both ways rather than asserted: a stub reporting 1.2.4 fires the banner; a stub reporting 1.3.0 is silent; an unparseable version fires; a flake.nix pinning a bare commit fires the cannot-read-the-pin arm. With the real binaries BOTH of today candidates pass (the flake store path and ~/.nix-profile), which is exactly the ticket point -- today pass proves nothing.\n\nHALF 1 IS THE USER TO MAKE, and is reported rather than done: create ~/.config/direnv/direnv.toml (it does not exist today) with [whitelist] prefix = [\"\u003crepo root\u003e\"]. One prefix covers the main checkout and every future worktree since worktrees live under .claude/worktrees/ inside it. Verified without touching the user file by pointing DIRENV_CONFIG at a scratch copy: same worktree went from not-allowed to allowed. There is NO repo-side alternative by design -- direnv refuses to let a repository authorize its own .envrc, and that refusal IS the security model; a .claude/settings.json env block prepending the flake store path was considered and rejected because it hard-codes a machine-specific nix hash that rots on every bump and is less trustworthy than the pin it claims to enforce.\n\nCORRECTION THIS TICKET SURFACED: the evidence in its original description read \"Found RC allowed 0\" as the broken state. It is the opposite -- direnv prints the AllowStatus enum where 0=Allowed, 1=NotAllowed. The conclusion still holds, on better evidence: this agent worktree (created after the users allow batch) reports allowed 1 and its allow file does not exist on disk.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3al1","title":"ADR-001 c: a rejected batch keeps its values staged, and class-1 force re-composes","description":"ADR 001 layer 1, step c. Depends on step b. This is the half that makes rejection usable.\n\nTWO OBLIGATIONS, both pulled forward from layer 2 by the user force requirement:\n1. The rejected batch VALUES STAY STAGED in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing what it already does; only PUBLISHING the staged keys is layer 2.\n2. FORCE, for conflict class 1 (two writers, same record): re-compose the same INTENT against the current state and submit a fresh batch with a CURRENT basis. One keystroke.\n\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is stale -- a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps.\n\nRULE: FORCE NEVER CREATES STRUCTURE. That rule is what keeps class 2 out of this ticket.\n\nTESTS: a rejected edit leaves the staged value intact; force after rejection lands the value and the basis is current at apply time; force does not re-run against the stale basis.","acceptance_criteria":"A rejected batch leaves the user's typing intact, and force re-composes the same INTENT against current state with a fresh basis — one keystroke. FORCE IS A RE-COMPOSITION, NEVER A BYPASS: a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps. FORCE NEVER CREATES STRUCTURE, which is what keeps conflict class 2 out of this ticket. Three tests: a rejected edit leaves the staged value intact; force after rejection lands the value with a basis current AT APPLY TIME; force does not re-run against the stale basis. Note the wasm client already keeps the Rejection itself rather than a status string, precisely so force knows which record and which kind (gotcha 43).","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":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:56Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-3al1","depends_on_id":"improvise-fkfg","type":"blocks","created_at":"2026-09-19T23:27:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-nxah","type":"related","created_at":"2026-09-21T21:59:56Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-xz2z","type":"blocks","created_at":"2026-09-24T00:43:43Z","created_by":"agent-227-fixes","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"improvise-3al1","title":"ADR-001 c: a rejected batch keeps its values staged, and class-1 force re-composes","description":"ADR 001 layer 1, step c. Depends on step b. This is the half that makes rejection usable.\n\nTWO OBLIGATIONS, both pulled forward from layer 2 by the user force requirement:\n1. The rejected batch VALUES STAY STAGED in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing what it already does; only PUBLISHING the staged keys is layer 2.\n2. FORCE, for conflict class 1 (two writers, same record): re-compose the same INTENT against the current state and submit a fresh batch with a CURRENT basis. One keystroke.\n\nFORCE IS A RE-COMPOSITION, NOT A BYPASS. Never apply a batch whose basis is stale -- a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps.\n\nRULE: FORCE NEVER CREATES STRUCTURE. That rule is what keeps class 2 out of this ticket.\n\nTESTS: a rejected edit leaves the staged value intact; force after rejection lands the value and the basis is current at apply time; force does not re-run against the stale basis.","acceptance_criteria":"A rejected batch leaves the user's typing intact, and force re-composes the same INTENT against current state with a fresh basis — one keystroke. FORCE IS A RE-COMPOSITION, NEVER A BYPASS: a mechanism whose only purpose is to violate the invariant is the invariant deleted with extra steps. FORCE NEVER CREATES STRUCTURE, which is what keeps conflict class 2 out of this ticket. Three tests: a rejected edit leaves the staged value intact; force after rejection lands the value with a basis current AT APPLY TIME; force does not re-run against the stale basis. Note the wasm client already keeps the Rejection itself rather than a status string, precisely so force knows which record and which kind (gotcha 43).","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":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:31Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:56Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-3al1","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-fkfg","type":"blocks","created_at":"2026-09-19T23:27:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-nxah","type":"related","created_at":"2026-09-21T21:59:56Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3al1","depends_on_id":"improvise-xz2z","type":"blocks","created_at":"2026-09-24T00:43:43Z","created_by":"agent-227-fixes","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-fkfg","title":"ADR-001 b: ModelCommand carries its basis; Applied gains a rejected case","description":"ADR 001 layer 1, step b. Depends on step a.\n\nModelCommand carries the basis it was composed against -- (document_generation, layout_version, and the record_versions of the records it touches). Sessions::apply rejects a batch whose basis moved.\n\nApplied grows a THIRD case beside Diff and Rebased. An enum, not a flag: matching is what forces a host to handle it, which is exactly the reasoning gotcha 39 records for rejecting Applied { .., rebase: bool }.\n\nTHE TIERS ARE THE POINT: a CellData batch is stale if LAYOUT moved, because the shape its keys hang off changed. That is the corruption class -- A deletes category Region while B commits a key naming Region=East -- detected without any lock, and it is why a single counter is not enough.\n\nRejection is a NORMAL OUTCOME, not an error. The client already has the new state via projections.\n\nTESTS, both arms per doctrine(one_arm_of_a_symmetric_rule...): a stale CellData batch after a layout change is rejected; a fresh one is not; a batch touching an untouched record is not rejected by another record moving; a WholeWorkbook bump invalidates everything. Beware doctrine(a_rename_can_hide_a_stale_key) -- hold identifiers constant and vary only what makes the basis stale.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Done. ADR 001 layer 1, the correctness floor.\n\nTHE THREE THINGS THE ADR IS EMPHATIC ABOUT:\n * NO BYPASS, AND NONE DESIGNED IN. Basis has NO Default and NO public constructor -- a zero basis is not 'no claim', it is the claim that the document is fresh, which in a new App is TRUE, so a default would be forgeable. BasisCache::claim is the only door a client has and it withholds until the server has published one. There is no force flag anywhere; re_composing_against_the_republished_basis_is_accepted walks the actual force path (refused -\u003e fold the republished basis -\u003e re-compose -\u003e accepted) with nothing that skips the check.\n * STRUCTURAL AND DATA KEPT APART. RecordGone (nothing resolves, nothing to write to, nothing coherent to force -- force never creates structure) vs RecordMoved (resolves at another version, force is one keystroke). The RecordGone test deliberately brings the LAYOUT claim up to date first so the tier above cannot mask it.\n * COUNTERS ON THE App. Nothing added to ViewState; cursor_epoch went beside document_generation for the same reason.\n\nWHERE THE ADR MET THE CODE AND WAS WRONG -- the important part. ADR 001 says 'a batch carries the basis it was composed against' and NEVER SAYS HOW A CLIENT COMES TO KNOW ONE. Nothing in the projection stream carried any of the three tiers, so the check as specified was satisfiable only by a client FORGING a basis: the invariant deleted with extra steps, arriving through the front door. Closed with Projection::Basis + BasisCache -- the server publishes the two document tiers plus the write counters for the records THIS CLIENT'S GRID NAMES (exactly the set it can address a write to, per ADR 002), diffed against what that client was last told and seeded in the bootstrap. Emitted AFTER the grid updates in a batch: the counters describe cells those updates carry, so a basis arriving first would let a client claim a version for a value it had not been shown. Mirror image of gotcha 33. The ADR is amended.\n\nTHREE SMALLER UNDER-SPECIFICATIONS, all now decided:\n * 'Any record the batch touches' -- WHOSE LIST? If the client's, a client that omits a record has a FORCE BY OMISSION: the record tier skipped, no flag to remove, nothing in the message saying so. records_named is the server's own exhaustive read of the ModelCommand (one or-pattern for the 24 that name none, so a new record-writing variant fails the build), and a claim that omits a record the server knows is written gets Rejection::RecordUnstated -- its own case because 'you lost a race' and 'you were never in one' are different things to tell a user.\n * Command::SwitchView carries no basis -- it is LayoutGeometry but sits outside Command::Model, so apply_switch is unchecked. Not a corruption risk (the workbook refuses a view it does not have, and every mirror takes what was ACCEPTED), but a hole in the rule. Filed.\n * reduce_full/apply_command do not check, and that is right: the check is a property of the SESSION DOOR, not of the mutation path, because a host holding the App is the truth rather than a claimant. ui::reduce::caught_up(\u0026App, cmd) serves that host and is not a bypass -- it takes \u0026App, so nothing reachable from a wire can call it.\n\nClient side: the wasm client folds Projection::Basis and BasisCache::claim gates both compose sites, so a client with no basis composes nothing.","dependencies":[{"issue_id":"improvise-fkfg","depends_on_id":"improvise-3x3u","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-fkfg","depends_on_id":"improvise-5a5y","type":"blocks","created_at":"2026-09-19T23:27:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-5a5y","title":"ADR-001 a: version tiers on App (layout_version, record_version)","description":"ADR 001 layer 1, step a -- the foundation. No wire change, no UX; testable alone.\n\nAdd beside App::document_generation (which already exists and is bumped by install_workbook):\n * layout_version -- bumped by any effect declaring ModelChange::LayoutGeometry\n * record_version -- per RECORD, bumped by a CellData effect touching that record\nA record is the coordinate tuple EXCLUDING _Measure -- record_row_identity(wide, coord_group_excluding_measure), already in kb.pl. NOT the cell: gotcha 1 says editing a category column moves every measure cell in the row, so a cell-level unit is violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.\n\nThese are SHARED DOCUMENT STATE, so they live on App next to document_generation, never on ViewState (gotcha 30).\n\nBump them from the same place Dirty is computed, so the taxonomy has one reader: App::apply_effects already does dirty.union(Dirty::of(effect.kind())). Do not add a second classification table -- gotcha 35.\n\nTESTS: a LayoutGeometry effect bumps layout_version and not record_version; a CellData effect bumps only the records it touched; an aborted batch (gotcha 21) bumps nothing it did not run. Prove each by injection.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:28Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:34Z","closed_at":"2026-09-19T23:34:34Z","close_reason":"App::layout_version and App::record_versions added beside document_generation, with accessors and a free record_of(\u0026CellKey) that makes record_row_identity a function. 933 tests passing.\n\nTHE DESIGN PROBLEM WORTH KEEPING: an EffectKind says THAT a batch writes cells, never WHICH. So layout_version reads cleanly off the taxonomy in the apply_effects loop, but record_versions cannot. Rejected adding a per-effect \"which records\" method -- a 50-effect burden and exactly the second table gotcha 35 warns against -- in favour of OBSERVING THE DATA: a per-record XOR fingerprint taken when the batch declares a CellData effect, diffed after the loop. The O(cells) pass is bounded by the O(cells) rebuild_layout() that already ends every batch and is paid only on editing batches.\n\nTwo documented semantic consequences: a write landing the value already there bumps nothing (correct -- no basis was invalidated, so rejecting for it would be a rejection for nothing); and a LayoutGeometry effect that drops cells bumps their records when sharing a batch with a CellData one (an over-bump, the safe direction, the same asymmetry Dirty::of is deliberately sloppy in).\n\nA REAL BUG THE TESTS CAUGHT DURING DEVELOPMENT: the first bump_changed_records chained before.keys() with after.keys() and incremented per name, so any REWRITTEN record counted as two writes. It is a HashSet now. Precisely the class of error a weaker fixture would have hidden -- the fixture is deliberately two categories, two measures, two records, because a one-record fixture cannot tell \"the record it touched\" from \"every record\" and a one-measure fixture cannot tell a record from a cell.\n\n7 tests including a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure (the test that earns the record unit) and the_version_tiers_are_not_in_the_view_state. Five faults injected, each fired.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-3x3u","title":"Resolve the two unaddressed nullary wire variants before concurrency lands","description":"ADR 001 names this as a prerequisite. ApplyAndClearDrill and AcceptWizardModel are routed by Sessions::apply with NO SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat already collides with the user ruling on improvise-tom1 (staged edits are private to whoever staged them); committing them on someone else behalf is the stronger version of the same violation. ADR 001 makes it untenable outright, because layer 2 turns staged keys into reservations held BY a session -- and a command that commits them cannot be unaddressed.\n\nTWO HONEST OPTIONS:\n (a) make them addressed -- carry a SessionId, and the effect acts only on that session staged state, which means the staging map moves off the single App and onto the session;\n (b) remove them from the wire vocabulary and keep them key-path only.\n(b) is cheaper and consistent with tom1. (a) is what layer 2 eventually wants if staged state becomes per-session anyway. Decide deliberately -- note that (b) is not a permanent answer if multi-client drill is ever wanted.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:55Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:34:33Z","closed_at":"2026-09-19T23:34:33Z","close_reason":"DECIDED: option (b) -- ApplyAndClearDrill and AcceptWizardModel removed from ModelCommand (28 -\u003e 26 variants). Their effects are unchanged and still reached by the key path.\n\nThe bug was DEMONSTRATED before the fix, not assumed: a temporary test against Sessions::apply -- client A stages a drill edit, client B sends Model(ApplyAndClearDrill) -- PASSED on the pre-change code, committing A uncommitted 99 into the document and clearing A drill session.\n\nRejected (a), carrying a SessionId: it is the honest long-term form but requires the staging map to move off the single App onto the session, which IS ADR 001 layer 2. Landing it inside the correctness floor would make the branch unreviewable. The doc comment says so, so (b) is not mistaken for permanent.\n\nTHE MORE DURABLE PART IS THE CORRECTED CRITERION. Gotcha 38 kept these on the grounds that \"the command is portable even where the operand is not\". Portability was the wrong question. Gotcha 36 already states the right one: an unaddressed command is correct exactly when the change is addressed to NO ONE session -- and these are addressed to precisely one, which is never the sender.\n\nThe demonstration cannot survive its own fix (the variant it sends is gone), so the surviving guard is structural: no_model_command_commits_another_clients_session_local_state walks every_model_command() and forbids either effect, plus a companion asserting both effects still exist and are still model changes, so the first cannot pass by naming nothing. Both proven by injection.","dependency_count":0,"dependent_count":1,"comment_count":0} @@ -389,20 +467,20 @@ {"_type":"issue","id":"improvise-60z","title":"Virtual views _Records and _Drill should not be persisted to .improv files","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T11:13:29Z","created_by":"Edward Langley","updated_at":"2026-07-31T16:04:38Z","started_at":"2026-07-31T07:26:39Z","closed_at":"2026-07-31T16:04:38Z","close_reason":"format_md skips views where View::is_virtual(); Initial View falls back to the first real view; parse_md drops stale virtual views so older files self-heal. Three regression tests in persistence.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c5v","title":"Improve records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)","description":"Current records view uses long format with synthetic 'Value' column. Editing values requires being on the 'Value' column; editing measure column renames the coordinate. This is awkward for typical data entry. Switch to wide format: one column per measure with direct value editing. Update build_records_mode, records_display, cell_key, resolve_display, DrillState pending edits, tests, and related effects. Red-green-refactor cycle. Extends/ reopens improvise-rbv.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-15T10:34:46Z","created_by":"Edward Langley","updated_at":"2026-08-29T19:29:18Z","started_at":"2026-08-29T19:13:28Z","closed_at":"2026-08-29T19:29:18Z","close_reason":"Records mode switched from long format (synthetic 'Value' column + a _Measure column) to wide format: a row is one coordinate tuple, each raw measure is its own editable column, and a trailing '+' column creates new measures.\n\nDerived the case matrix in Prolog rather than guessing it, which surfaced the load-bearing case the description does not mention: in wide format a row GROUPS one cell per measure, so editing a category column must move every one of those cells. The long-format code moved only the cell under the cursor, which would have torn rows in half — half the measures under the old coordinate, half under the new. Both edit paths (plain records and staged drill edits) implement it; both are tested.\n\nDesign decisions:\n- _Dim in the synthetic CellKey now carries the column INDEX, not a label. A category and a measure item can share a name, so a label cannot say which column an edit landed on. Resolved through GridLayout::record_col -\u003e RecordCol {Category, Measure, NewMeasure}. This rippled through synthetic_record_info, CmdContext::synthetic_record_at_cursor, DrillState.pending_edits (now keyed by (row, RecordCol), which also removes ApplyAndClearDrill's dependency on the layout still being the drill layout), and the grid renderer.\n- Measure columns are the raw _Measure items only. Formula targets are computed, so there is nothing to type into them.\n- The '+' column was the user's call over two alternatives, because a blank model has no measures and wide format would otherwise leave nowhere to enter a number — that is the onboarding path pinned by the old edit_record_row_in_blank_model_persists_value test. Adding a measure is a schema change, so it applies immediately even inside a drill rather than being staged.\n\nVerified end-to-end, not just by unit tests: tests/records_wide_smoke.rs imports a real CSV, loads the .improv, and asserts the records grid is two rows with both measures side by side. My first two expectations there were wrong (measure order and row order come from the file/store, not the source CSV) — the code was right both times.\n\nSix existing tests encoded long-format behavior and were rewritten, not deleted: the two-row helper now uses two Region items rather than two measures on one, and the blank-model test drives the new '+' flow.\n\nAlso removed prune_empty's records branch, which was unreachable behind the function's own early return; filed improvise-2yk in case the early return is itself the bug.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-edp","title":"Establish ViewModel layer: derived data between RenderCache and widgets","description":"Add a classical MVVM ViewModel layer between the RenderCache (wire-format data sent from server) and the widgets (pure renderers). The ViewModel is computed from RenderCache + ViewState + client-local concerns (terminal size, fonts, theme), memoized on state change, and consumed by widgets. Decouples widgets entirely from cache shape — they only know about the ViewModel they consume. Shared concept across native ratatui and browser DOM rendering; both consume viewmodels rather than caches directly.","design":"For each rendering surface, a pair of types: (1) SurfaceCache — the data the server sends over the wire (raw cells, labels, indices). (2) SurfaceViewModel — the render-ready derived data (styled cell display with highlighting, pre-computed visible row range, column positions in terminal coordinates, cursor-region flags). Paired with a pure function fn compute_surface_viewmodel(cache: \u0026SurfaceCache, view: \u0026ViewState, render_env: \u0026RenderEnv) -\u003e SurfaceViewModel. render_env holds client-local concerns: terminal dimensions for native, window dimensions + device pixel ratio for browser, color theme, unicode width handling. Memoization: recompute viewmodel when cache or view state changes; skip when only render_env changes trivially. Widgets read \u0026SurfaceViewModel — entirely decoupled from cache shape. Benefits: (a) widgets are unit-testable with hand-crafted viewmodels, (b) viewmodel logic is testable without any rendering, (c) ratatui and DOM renderers can share the same viewmodel derivation code, (d) memoization prevents redundant recomputation.","acceptance_criteria":"(1) At least one pair (GridCache, GridViewModel) defined with compute function. (2) GridWidget and DOM grid renderer both consume GridViewModel. (3) Unit tests for viewmodel derivation covering edge cases (empty grid, wide cells, highlights). (4) Memoization hook so recomputation is skipped when neither cache nor view state has changed.","notes":"Sits between improvise-35e (cache adapter) and the widget migration issues (improvise-n10, -pca, -jb3, -764). Each widget migration issue should read its viewmodel, not its cache directly. Shared with the browser epic: improvise-cr3 (DOM renderer) should also consume viewmodels. This is the refinement the user asked for — 'the viewmodel is probably a good idea as well'.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.\n\n--- UNBLOCKED: improvise-vjms decided (user, 2026-09-19) ---\nThe category panel question is answered: the TUI KEEPS the virtual categories\nand the BROWSER GETS THEM TOO. CategoryTreeCache grows to carry _Index, _Dim\nand _Measure so both panels render the same thing from one source.\n\nTHE TRAP TO AVOID when implementing: _Measure items are DERIVED, not stored --\ngotcha 7 says they are the explicit items UNION the formula targets, and\nadd_formula does not add items to _Measure. So category_tree_of must compute\nmembership through measure_item_names()/effective_item_names(), NEVER through\ncategory(\"_Measure\").items, or the panel silently omits every formula target.\nimprovise-ete was closed for proposing exactly that raw-list getter.\n\nAlso: CategoryTreeCache doc comment says \"the client does not know the\n_-prefixed virtual-category convention\" -- that is now false and must be\nremoved rather than left to mislead.\n\nThen build_cat_tree can migrate off \u0026Model (it takes \u0026Model today), which is\nwhat actually unblocks the category panel and this ticket. improvise-35e\nmigrated the other three panels already; this is the fourth.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:04Z","created_by":"spot","updated_at":"2026-09-20T00:25:56Z","closed_at":"2026-09-20T00:25:56Z","close_reason":"PREMISE LARGELY FALSE; the one real piece was built and the rest should not be.\n\nThe ticket asked for a SurfaceViewModel per SurfaceCache plus compute_*(cache, view, render_env). Evidence it does not apply: (a) gotcha 32 already removed the aggregate RenderCache the ticket names, and (b) after improvise-35e the widget seam is ALREADY one cache plus \u0026AppMode -- FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str). A per-widget cache built by project.rs is already the render-ready shape: CellDisplay carries formatted strings, GridCache carries col_widths. There is no cache-shape to decouple a widget from. render_env is likewise handled -- the terminal extent enters through ClientSession::set_extent, and inventing a Command for it is forbidden by decision(improvise-cqi, command_set_derived_not_invented).\n\nWHAT WAS REAL AND GOT BUILT: the category panel flattening, which genuinely sits between a cache and a renderer because it mixes CategoryTreeCache with per-client ViewState::expanded_cats. It lives in improvise-protocol::viewmodel (CatTreeEntry + flatten_cat_tree), NOT improvise-command, because improvise-bpq2 cannot reach improvise-command -- the wasm client Cargo.toml excludes it deliberately -- and move-panel-cursor walks this flattening. Both expanded_cats and cat_panel_cursor already live in protocol ViewState, so the derivation inputs and its consumer index are all in that crate. Putting it in improvise-command would have guaranteed bpq2 re-derives it client-side and the two ends drift.\n\nALSO DELIVERED: improvise-vjms. CategoryTreeCache now carries every category; the stale \"the client does not know the _-prefixed convention\" doc is gone; CatTreeEntry carries its Axis so CategoryContent takes a cache and an expanded set and no View -- the FOURTH AND LAST panel off \u0026Model. It also removes a latent panic: the widget called view.axis_of(name), which panics for a category the view has never seen.\n\nA LIVE SHIPPED BUG WAS FIXED, not a hypothetical one: build_cat_tree took \u0026Model and read cat.items.len()/ordered_item_names() -- the RAW list -- so the TUI category panel has been showing _Measure with the WRONG ITEM COUNT and OMITTING EVERY FORMULA TARGET, on main, today. That is the improvise-ete failure mode already shipped. Injecting it back fails exactly ONE test against 1023 green.\n\nNOT BUILT AND NOT WORTH A TICKET AS SPECCED: viewmodels for grid/formula/view/tile-bar. 1031 tests pass.","dependencies":[{"issue_id":"improvise-edp","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":6,"comment_count":0} -{"_type":"issue","id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete crates/improvise-command/src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign.\n\nDeferred as a campaign of its own, not as a rejection. e0u restructures the native binary around an in-process client/server split and deletes the RenderCache::from_app adapter -- it touches draw.rs, every widget, App and the projection layer, so it would conflict with essentially every other work unit in the backlog and would have to go last with everything rebasing onto it.\n\nThe cheap members of its cluster ARE being done now: improvise-2ud (CmdContext borrows the slices), improvise-drg (narrow Effect::apply where one-slice), improvise-s17o (decide reduce_full's fate), improvise-i5tk (benchmark the whole-grid projection first). Those are the parts that do not require the split.\n\nUn-defer when the browser/protocol backlog has settled and e0u can have a wave to itself.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-e0u","title":"Split native binary into in-process client + server, delete \u0026App adapter","description":"The architectural payoff: once every widget consumes RenderCache + ViewState, restructure the native binary so the ratatui UI is a proper client of an in-process server. Main loop has two halves: server holds the full App + projection layer + VFS persistence; client holds ViewState + RenderCache + ratatui widgets. Commands flow through a direct-call channel (zero-serialization in-process transport). Delete the temporary RenderCache::from_app adapter.","design":"Main loop restructure: fn main() spawns (a) a server loop that owns an App + projection layer + VFS storage (via improvise-6mq Storage trait backed by PhysicalFS for native), and (b) a client loop that owns a ViewState + RenderCache and renders ratatui. Communication via a tokio mpsc channel (or plain std::sync::mpsc since native is single-threaded) — no serialization because both sides hold the same Command enum in memory. Client captures keys → resolves via keymap → sends Command upstream → server calls reduce_full → projection commands come back down → client applies them via reduce_view → widgets redraw. Importantly, this is exactly the same message flow as the browser modes; the transport is just a function call. Delete crates/improvise-command/src/ui/app.rs's RenderCache::from_app adapter (issue 2). Native TUI's App now lives on the server side and is never read directly by widgets.","acceptance_criteria":"(1) Native binary has distinct client and server halves with a Command channel between them. (2) Ratatui widgets never read \u0026App. (3) RenderCache::from_app adapter is deleted. (4) All existing tests + integration tests pass. (5) Performance: frame latency unchanged from current native TUI (the projection work is now on the server side, the rendering work on the client side, but it's all the same process). (6) A structural lint: nothing in src/ui/ can import from src/model/ or src/command/ (only from improvise-protocol's cache types).","notes":"Terminal node of the required work in this epic. Depends on issues 1 (reduce_full), 2 (adapter), 3-6 (all widget migrations). After this lands, the native TUI and the browser modes share an identical architecture — only the transport and persistence backend differ.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign.\n\nDeferred as a campaign of its own, not as a rejection. e0u restructures the native binary around an in-process client/server split and deletes the RenderCache::from_app adapter -- it touches draw.rs, every widget, App and the projection layer, so it would conflict with essentially every other work unit in the backlog and would have to go last with everything rebasing onto it.\n\nThe cheap members of its cluster ARE being done now: improvise-2ud (CmdContext borrows the slices), improvise-drg (narrow Effect::apply where one-slice), improvise-s17o (decide reduce_full's fate), improvise-i5tk (benchmark the whole-grid projection first). Those are the parts that do not require the split.\n\nUn-defer when the browser/protocol backlog has settled and e0u can have a wave to itself.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:54Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-e0u","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-ltq","type":"parent-child","created_at":"2026-09-25T01:07:40Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-e0u","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":6,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-764","title":"Migrate import wizard widget and state to consume RenderCache","description":"Rewrite ui/import_wizard_ui.rs (347 lines) to consume a WizardCache instead of the ImportWizard struct directly. Also decide where the import wizard state itself lives in the new crate layout — probably needs to stay in improvise-command (not improvise-io) so it can be referenced from App without creating a dep cycle.","design":"WizardCache mirrors the ImportWizard state: current step, per-field decisions, preview rows, current validation errors. The widget reads cache + ViewState and draws. The ImportWizard state machine stays wherever it ends up living after the crate-split epic step 3 resolves its placement — either improvise-command (portable state machine) or improvise-io (tangled with CSV parsing). The separation between wizard state (session/view-ish) and import pipeline (pure data processing) is worth getting right because it affects the worker-server's ability to run imports.","acceptance_criteria":"(1) import_wizard_ui.rs no longer reads the wizard state directly — takes WizardCache. (2) ImportWizard is split into state + pipeline; state lives in an appropriate crate that doesn't cycle. (3) Native TUI wizard rendering unchanged. (4) Unit tests with fixture WizardCaches for each step.","notes":"Depends on improvise-edp (ViewModel layer). Wizard widget consumes WizardViewModel derived from WizardCache + ViewState. The wizard state machine itself (step tracking, field decisions) lives in the ImportWizard struct on the server side; the cache is a snapshot of its renderable state; the viewmodel adds any styling/layout derivation the widget needs.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-ltq improvise-e0u ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:47Z","created_by":"spot","updated_at":"2026-09-20T00:58:17Z","closed_at":"2026-09-20T00:58:17Z","close_reason":"Done, and the ticket's open design question is answered.\n\nThe widget is rewritten against a new WizardCache (+ WizardStepKind, WizardFieldKind, WizardProposal, WizardDateField) in improvise-protocol::render, built by project::wizard_cache / wizard_of. ImportWizard, ImportPipeline, FieldKind, DateComponent and Workbook all stop at the projection.\n\nTHE ANSWER TO 'WHERE DOES THE WIZARD STATE LIVE': THERE IS DELIBERATELY NO Projection::Wizard. A Projection is addressed to a widget on EVERY client, which is correct exactly when the change is addressed to NO ONE session. A half-open import dialogue belongs to precisely the session that opened it. This follows settled doctrine rather than re-deciding it -- ImportWizard is on App and not ViewState (gotcha 30), and AcceptWizardModel was cut from ModelCommand for exactly this reason (gotcha 38 / improvise-tom1). Broadcasting the wizard would re-create that bug on the RENDERING side: every client drawing one client's dialogue.\n\nSo: a cache WITHOUT a projection is the shape session-local residue takes when it still has to be drawn by two different front ends. This resolves the open question gotcha 32 parks on this ticket.\n\nThe type still lives in improvise-protocol rather than improvise-command for the flatten_cat_tree reason: the wasm client reaches only the protocol, and when it grows a wizard it must draw THESE rows, not a second derivation.\n\nMulti-client import wants the ADDRESSED form, which is ADR 001 layer 2.\n\nONE GAP: WizardStep::Preview is unreachable from both fixtures -- it needs a document that parses to zero records without needing path selection. Filed.","dependencies":[{"issue_id":"improvise-764","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:38Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-jb3","title":"Migrate help and which_key widgets to consume RenderCache + ViewState","description":"Rewrite the small static-content widgets: ui/help.rs (5-page help overlay, 617 lines but mostly static text) and ui/which_key.rs (prefix-key hint popup). Lowest-risk widgets in the migration because their content is almost entirely derived from static data plus a small amount of ViewState (help_page index, active transient_keymap).","design":"HelpCache: largely static; just the page-index state and the content pages (constant). Could even be stateless if the renderer reads help_page directly from ViewState. WhichKeyCache: derived from the active transient_keymap — a flat list of (key, binding description) entries. Each widget becomes a pure render function taking its cache + ViewState for minimal dynamic bits.","acceptance_criteria":"(1) help.rs and which_key.rs no longer import App. (2) Unit-testable with fixture caches (or no cache for help). (3) Native TUI rendering unchanged.","notes":"Depends on improvise-edp (ViewModel layer). Help widget consumes HelpViewModel (derived from the static help content + current help_page); which_key consumes WhichKeyViewModel (derived from the active transient_keymap). Low-complexity widgets, but go through the viewmodel layer for consistency with the rest of the migration.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-e0u improvise-ltq ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.\nSTALE ASSUMPTION (groomed 2026-08-30): this design refers three times to 'the active transient_keymap' as a ViewState field. That field is gone (improvise-jc8). ViewState now holds transient_prefix: Option\u003c(KeyCode, KeyModifiers)\u003e — the key that OPENED the prefix — and the sub-keymap is resolved on demand via KeymapSet::prefix_keymap(mode, key, mods), which walks parent keymaps.\n\nConsequence for the WhichKeyCache described here: it cannot be derived from ViewState alone any more. It needs ViewState (for the armed prefix) plus the keymap set (to resolve it and call binding_hints()). App::armed_prefix_keymap() does exactly that today and is the natural source. This matters because the whole point of jb3 is that widgets stop reading \u0026App — so decide whether the cache is populated server-side from the resolved keymap, or whether the hint list itself is what crosses to the client. The latter is probably right: hints are (label, command-name) pairs, which are portable, whereas a Keymap is not.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:50:23Z","created_by":"spot","updated_at":"2026-09-20T00:58:16Z","closed_at":"2026-09-20T00:58:16Z","close_reason":"The widget half was already done, and the ticket's own premise was stale -- its grooming note said so and was right.\n\nHelpWidget::new(help_page: usize) and WhichKeyWidget::new(\u0026[(String, \u0026'static str)]) were NEVER \u0026App-coupled; the git history shows no migration because none was needed. The which-key derivation happens in draw.rs, not in the widget: App::armed_prefix_keymap() + Keymap::binding_hints(). The widget is already a pure renderer of the hint list.\n\nWHAT REMAINS IS A PROTOCOL GAP, NOT A WIDGET PROBLEM, and it is filed as its own ticket: ViewState::transient_prefix is portable, but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone. Either the resolved hint list crosses (a projection or a ViewState field) or the client gets the keymaps. That is a real decision and it was never this ticket's.","dependencies":[{"issue_id":"improvise-jb3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-pca","title":"Migrate panel widgets (category/formula/view/tile_bar) to consume RenderCache","description":"Rewrite the smaller ratatui panel widgets so each one takes its relevant sub-cache slice instead of \u0026App. Targets: ui/category_panel.rs + cat_tree.rs, ui/formula_panel.rs, ui/view_panel.rs, ui/tile_bar.rs, ui/panel.rs (generic frame). Each becomes a pure renderer of its own cache structure.","design":"Four sub-cache types: CategoryTreeCache (flattened cat/item/group tree with expand state, current cursor index), FormulaListCache (raw strings + target info), ViewListCache (names + active flag), TileBarCache (per-category axis assignment). Each widget's render takes \u0026SubCache and \u0026ViewState (for mode / cursor). Widget signatures: fn draw_category_panel(frame, area, cache: \u0026CategoryTreeCache, view: \u0026ViewState); same shape for the others. The flattening logic that's currently in ui/cat_tree.rs (build_cat_tree) moves into cache construction rather than happening at render time. Handcrafted fixture caches for unit tests. Panels become entirely render-only; no App dependency. Shared with the browser/standalone clients later if they ever add panel rendering (post-MVP for those).","acceptance_criteria":"(1) Each of the four panel widgets no longer imports App. (2) Each is unit-testable with a fixture sub-cache. (3) Native TUI renders identically to before. (4) Sub-cache types live in improvise-protocol so they can be serialized for remote clients later.","notes":"Depends on improvise-edp (ViewModel layer). Each panel widget consumes its own sub-viewmodel (CategoryTreeViewModel, FormulaListViewModel, ViewListViewModel, TileBarViewModel), computed from the corresponding sub-cache + ViewState. Panels become pure renderers of viewmodels; all flattening, ordering, and highlight logic lives in the compute_*_viewmodel functions.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:06Z","created_by":"spot","updated_at":"2026-09-20T00:58:16Z","closed_at":"2026-09-20T00:58:16Z","close_reason":"Already done -- delivered by improvise-35e (92fd3a8) and improvise-vjms (986af9e), both already on main. No work was needed and none was invented.\n\nEVIDENCE: CategoryContent::new(\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e), FormulaContent::new(\u0026FormulaCache, \u0026AppMode), ViewContent::new(\u0026ViewListCache, \u0026str), TileBar::new(\u0026TileBarCache, \u0026AppMode, usize). None takes \u0026App, \u0026Model, \u0026View or \u0026Workbook outside its test module.\n\nONE RESIDUAL DERIVATION, filed separately rather than fixed: ViewContent::axis_summary hard-codes !cat.starts_with('_') to filter virtual categories out of the axis summary, while ViewSummary::axes carries EVERY category. That is the same convention improvise-vjms deliberately pushed onto the client for the category panel, still applied locally by the view panel. Not a bug today -- there is no browser view panel -- but it is a second, divergent answer waiting.","dependencies":[{"issue_id":"improvise-pca","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-n10","title":"Migrate GridWidget to consume RenderCache + ViewState (not \u0026App)","description":"Rewrite ui/grid.rs so that GridWidget::render takes \u0026GridCache + \u0026ViewState instead of reaching into \u0026App. The grid is the largest and most important widget — 1036 lines, mixes layout calc + ratatui drawing + direct App reads. The migration separates 'what to draw' (data in GridCache) from 'how to draw it' (ratatui primitives). Also aligns with improvise-cr3 (browser DOM renderer), which renders the same GridCache shape to DOM — share the GridCache type between them.","design":"pub struct GridCache { pub col_widths: Vec\u003cu16\u003e, pub row_labels: Vec\u003cString\u003e, pub col_labels: Vec\u003cString\u003e, pub cells: Vec\u003cVec\u003cCellDisplay\u003e\u003e, pub cursor: (usize, usize), pub highlights: Vec\u003cHighlight\u003e, pub mode_indicator: ModeIndicator, ... }. GridWidget becomes: fn draw_grid(frame: \u0026mut Frame, area: Rect, cache: \u0026GridCache, view: \u0026ViewState). All current dynamic calculations (col width from content, layout metrics, records mode detection) move to GridCache construction in the projection layer, not at render time. Widget is pure drawing. The temporary RenderCache::from_app adapter (issue 2) populates GridCache from App+View+GridLayout; later the projection layer computes it server-side for remote clients. During this migration, the widget is tested against a handcrafted GridCache fixture — no App needed. Share GridCache type with improvise-cr3 by placing it in improvise-protocol.","acceptance_criteria":"(1) GridWidget no longer imports App or reads anything outside GridCache + ViewState. (2) Widget is unit-testable with a fixture GridCache. (3) Native TUI renders identically to before for every .improv file in tests/. (4) GridCache type is shared with improvise-cr3 (both rasterize the same data structure). (5) Performance: per-frame render time unchanged (the projection layer work happens outside the widget, not inside).","notes":"Depends on issue improvise-edp (ViewModel layer) — the grid widget consumes GridViewModel (client-computed from GridCache + ViewState + render env), not GridCache directly. Share design work with improvise-cr3 (browser DOM renderer) — both target the same GridViewModel type, differing only in the rendering backend.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:49:03Z","created_by":"spot","updated_at":"2026-09-20T00:58:15Z","closed_at":"2026-09-20T00:58:15Z","close_reason":"Done, partially -- and the part left undone is the finding.\n\nGridWidget no longer holds a \u0026Model. Three derivations moved:\n * COLUMN TOTALS -\u003e GridCache::totals + project::column_totals. The widget was summing every row of each visible column through evaluate_aggregated_f64. This MUST be projected, not client-derived: the sum runs over every row INCLUDING THE ONES A VIEWPORT SCROLLED PAST, so a client holding only its window cannot compute it. An EMPTY strip means 'no totals' (records mode / empty grid) -- deliberately not a row of blanks, and fit_to_labels pads it only once non-empty. Invalidated blanks the totals: they are contents, and a stale sum under fresh cells is the one number a user takes at face value.\n * PAGE BAR -\u003e GridCache::page_coords, framed by nothing (it names categories, not positions) so it survives a reshape.\n * THE ITALIC-AGGREGATE RULE -\u003e read off CellId::Aggregate (ADR 002), which already carried the fact. The widget had been walking none_cats x Model::category(..).kind.is_regular() -- a second, hand-rolled copy of what project::cell_id decides.\n\nNOT DONE, DELIBERATELY: THE GRID'S FRAME. Six facts still come off GridLayout + View -- header level count (row_cats/col_cats), multi-level label TUPLES and inline GROUP HEADERS (row_items), column group spans (col_group_for), the collapse indicator (is_group_collapsed), and record_col (session-local by design, since it keys pending drill edits -- that one is CORRECT to keep off).\n\nTHE HEADLINE, AND IT IS A LIVE DIVERGENCE TODAY: GridCache::row_labels is GridLayout::row_label, which JOINS A MULTI-LEVEL TUPLE WITH '/'. That is exactly the rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must NOT appear. So the protocol carries a form of the labels the TUI considers wrong, and the wasm client renders precisely that form: the browser grid shows Food/Alice where the TUI shows two header columns, with no multi-level headers, no repeat suppression, no groups and no collapse indicators. Successor ticket filed -- it is a co-ordinated change (row_labels/col_labels change TYPE and improvise-wasm-client consumes them), and an additive row_levels beside row_labels is the wrong fix, because two answers to 'what are the labels' is the disease.\n\nUNVERIFIED: project::grid_of now evaluates every cell per frame where GridWidget evaluated only the visible ones. The totals loop was already O(rows x visible cols) and compute_col_widths was already whole-grid, so this WIDENS an existing cost rather than introducing one -- but on a wide grid it is a real widening and there is no benchmark. The fix would be to give the TUI a real viewport, which needs the width/window chicken-and-egg untangled first.","dependencies":[{"issue_id":"improvise-n10","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-35e","title":"Temporary adapter: RenderCache::from_app bridge for incremental widget migration","description":"Build a function that constructs a complete RenderCache from an \u0026App on every frame. This is a temporary bridge that lets ratatui widgets migrate from '\u0026App direct reads' to 'RenderCache consumer' one at a time, without breaking the build. Each migrated widget reads from the adapter's output; unmigrated widgets keep reading \u0026App. When every widget has been migrated and the binary split is complete, this adapter is deleted.","design":"pub fn RenderCache::from_app(app: \u0026App, subs: SubscriptionSet) -\u003e RenderCache. Walks the full App state and populates every subscribed-to sub-cache: grid cells/labels/widths, category tree, formula list, view list, tile bar axes, wizard state, etc. SubscriptionSet is the full set initially (native TUI subscribes to everything); later issues refine it per client. Called on every frame from run_tui before drawing. Unperformant by construction (rebuilds everything per frame) but correct, and it's throwaway code. Cache shape: pub struct RenderCache { grid: Option\u003cGridCache\u003e, category_tree: Option\u003cCategoryTreeCache\u003e, formulas: Option\u003cFormulaListCache\u003e, views: Option\u003cViewListCache\u003e, tile_bar: Option\u003cTileBarCache\u003e, wizard: Option\u003cWizardCache\u003e, help: Option\u003cHelpCache\u003e, ... }. Each field is Some if subscribed and populated from App data.","acceptance_criteria":"(1) RenderCache struct exists with optional sub-caches for every rendering surface. (2) from_app populates all subscribed sub-caches from current App state. (3) run_tui calls from_app once per frame before drawing. (4) Tests demonstrate the cache contents match what the corresponding widgets would read from App directly.","notes":"Throwaway code explicitly marked as such. Deleted after issue 7 (binary split) lands. Depends on issue 1 (reduce_full) only because it needs to know what the cache shape should be, which is determined by what widgets read.\nGROOMED 2026-09-19: the RenderCache that actually landed in improvise-cqi is grid-only (row_labels/col_labels/col_widths/cells) — not the 'grid + category_tree + formulas + views + tile_bar + wizard + help' shape this ticket's design describes. Blocked on improvise-rkeq until the shape is decided; writing from_app against the wrong one is the expensive mistake.\n--- DECIDED 2026-09-19 (user) ---\nRenderCache is PER-WIDGET, and state synchronizes by EVENTS, not snapshots.\n\nThis is shape (C) from improvise-rkeq, with per-widget caches:\n * No flat grid-only wire type and no fat aggregate container. Each widget\n owns its own cache type (GridCache, CategoryTreeCache, FormulaCache,\n ViewCache, TileBarCache, WizardCache, HelpCache).\n * Each widget subscribes to and FOLDS its own stream of projection\n Commands. There is no aggregate RenderCache on the wire.\n * The ONE exception is the initial state transfer: a newly connected\n client gets a Snapshot to seed from, then follows the event stream.\n Snapshot stays bootstrap-only (wire_carries(snapshot,bootstrap_only)).\n\nConsequence for this ticket: read \"RenderCache\" as \"that widget's own cache\ntype\", and expect updates to arrive as folded projection commands rather\nthan as a rebuilt snapshot.","status":"closed","priority":2,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:38Z","created_by":"spot","updated_at":"2026-09-19T23:40:22Z","closed_at":"2026-09-19T23:40:22Z","close_reason":"PREMISE CHECK FIRST, and the ticket as written is DEAD: RenderCache::from_app(app, subs) -\u003e RenderCache cannot be written because that aggregate does not exist and will not (improvise-rkeq, gotcha 32). It was not written. The builders it asked for already existed and were already pub -- grid_of, category_rows, formula_texts, view_summaries, tile_entries -- and Dirty already answers the SubscriptionSet, so no parallel type was invented.\n\nTWO THINGS WERE GENUINELY MISSING, both real:\n1. THE ROOT CRATE COULD NOT NAME A CACHE TYPE. improvise had no improvise-protocol dependency and improvise-command re-exported core/formula/io but NOT protocol, so no ratatui widget in src/ could write fn new(cache: \u0026FormulaCache). That was the hard blocker and nothing complained because nobody had tried. Fixed by re-exporting the protocol through improvise-command (and the root crate), so a host names the caches through the crate that builds them and the root takes no new dependency.\n2. THE BUILDERS RETURNED PAYLOADS, NOT CACHES. Added category_tree_of/formulas_of/view_list_of/tile_bar_of, each one line over the existing payload fn so there is no second derivation. document_projections and the tests server_truth now both derive FROM the caches, so what a widget holds and what a client folds cannot disagree.\n\nThree widgets migrated off \u0026Model/\u0026View: formula panel, view panel, tile bar. No aggregate type and no adapter type -- a caller wanting several caches calls several builders, and no widget names the seam, so deleting the temporary half is deleting call sites.\n\n6 tests, each an EQUIVALENCE between the cache and what the widget used to read off the model (plus pinned render output where drawing has layout logic). Three faults injected into the builders, all fired. 970 workspace tests pass.","dependencies":[{"issue_id":"improvise-35e","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:34Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-35e","depends_on_id":"improvise-rkeq","type":"blocks","created_at":"2026-09-19T12:49:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-gxi","title":"Define unified reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e","description":"Extract the single shared reducer function that all three deployment modes use to apply a Command to the authoritative state. Replaces today's App::handle_key → Cmd::execute → Effect::apply chain with a named function that takes a Command (data, not trait object), runs the command through the existing registry + effect pipeline, and returns any outbound projection commands. Load-bearing for the entire unified architecture — native TUI, ws-server, and worker-server all import this same function.","design":"Signature: pub fn reduce_full(app: \u0026mut App, cmd: \u0026Command) -\u003e Vec\u003cCommand\u003e. Internally: (1) resolve the Command to a Cmd trait object via the registry (or match directly on the Command enum), (2) build a CmdContext from the current App state, (3) run execute() to produce effects, (4) apply each effect to App, (5) walk the effect list and compute outbound projection Commands for each subscribing client (empty if no subscribers). The function lives in improvise-command (which requires App to move there too — see epic notes). Command enum is defined in improvise-protocol (improvise-cqi). The native TUI's existing App::handle_key becomes a thin wrapper: resolve the key to a Command via the keymap, call reduce_full, drop the returned projections (single-session native has no remote subscribers). The ws-server and worker-server call reduce_full directly on received Commands and dispatch the returned projections back over their respective transports.","acceptance_criteria":"(1) pub fn reduce_full exists in improvise-command. (2) App::handle_key is rewritten to resolve key → Command via keymap, then call reduce_full, then drop projections. (3) All existing native TUI behavior is preserved — integration and unit tests pass unchanged. (4) Keymap lookup returns Command values (data) rather than Box\u003cdyn Cmd\u003e trait objects. (5) CmdRegistry's dispatch API becomes 'dispatch_command(cmd: \u0026Command, ctx: \u0026CmdContext) -\u003e Vec\u003cEffect\u003e' or equivalent. (6) A unit test demonstrates reduce_full applied to a Command produces the same final App state as the old path.","notes":"Depends on improvise-cqi (Command enum defined in improvise-protocol) and on App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes). Cross-epic: improvise-cqq (browser projection layer) formally depends on this issue since it needs reduce_full to exist.\nGROOMED 2026-09-19 — PREREQUISITES DISSOLVED. The notes said this depends on \"App having moved to improvise-command (a crate-epic step 5 detail that needs updating — see improvise-3mm notes)\". improvise-3mm is closed and App already lives at crates/improvise-command/src/ui/app.rs, so that prerequisite is satisfied, not pending. improvise-cqi is closed too: Command, ViewState and reduce_view are in improvise-protocol, and improvise-command depends on it. Nothing blocks this now — it is in bd ready.\n\n--- PARTIALLY LANDED 2026-09-19; ACs 4-5 BLOCKED, deliberately not forced ---\nDONE: reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e (AC 1), apply_command\n(the inner half a server uses to drive Sessions::project_all), reduce_key,\nand App::handle_key is now a wrapper over App::dispatch_key(key) -\u003e Dirty\n(AC 2 in spirit). All existing behaviour preserved (ACs 3, 6).\n\nNOT DONE, and not doable as written: ACs 4 and 5 ask that keymap lookup\nreturn Command values and that CmdRegistry gain dispatch_command(\u0026Command).\nThe Command enum's client-\u003eserver half is exactly the ViewOnly portable-slice\nmutations -- cqi derived it from those effects and nothing else -- so it has\nno SetCell, AddCategory, SetAxis or Save. A keymap cannot return a Command for\nmost bindings, and reduce_full over a Command cannot drive a model mutation.\nForcing it would mean inventing variants ahead of the mutations, which\ndecision(improvise-cqi, command_set_derived_not_invented) and\ndoctrine(derive_the_enum_from_the_effects) both forbid.\n\nBLOCKED ON: the new ticket to derive the model half of Command from the 29\nEffectKind::Model effects. Until then reduce_full and reduce_key are two\ndoors and ui/reduce.rs's module doc explains why.\n\n--- AC 4 IS UNREACHABLE BY DESIGN, not for want of vocabulary (2026-09-19) ---\nimprovise-kmgs landed ModelCommand, so the \"the vocabulary is missing\" reason\nis gone. AC 4 still cannot be met, and the sharper reason is worth recording:\n\na keymap binding names an INTENT, resolved against CmdContext -- \"move-cursor\ndown\". A Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which\nonly exists once the cursor and layout have been read. So a keymap LOOKUP can\nnever return a Command; only dispatch, after CmdContext, can. That is a\nproperty of the Cmd/Command split itself, not a gap.\n\nRECOMMEND: rewrite AC 4 to \"dispatch (not lookup) yields a Command where the\nbinding resolves to a model mutation\", or drop it. Do not force the original\nwording.\n\nAC 5 is satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn\nEffect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". It was deliberately NOT hung\noff CmdRegistry as dispatch_command, because the mapping needs neither the\nregistry nor a CmdContext and a method ignoring \u0026self would misdescribe it.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:36Z","created_by":"spot","updated_at":"2026-09-19T23:29:48Z","started_at":"2026-09-19T21:15:06Z","closed_at":"2026-09-19T23:29:48Z","close_reason":"CLOSED, re-scoped: ACs 1-3 and 6 delivered; ACs 4-5 resolved as written-against-a-false-premise.\n\nDELIVERED (wave 2, merged): reduce_full(\u0026mut App, \u0026Command) -\u003e Vec\u003cCommand\u003e, apply_command (the inner half a server uses to drive Sessions::project_all), reduce_key, and App::handle_key as a wrapper over App::dispatch_key(key) -\u003e Dirty. All existing behaviour preserved.\n\nAC 5 satisfied in substance: model_effects(\u0026ModelCommand) -\u003e Vec\u003cBox\u003cdyn Effect\u003e\u003e in ui/reduce.rs is the \"or equivalent\". Deliberately NOT hung off CmdRegistry as dispatch_command, because the mapping needs neither the registry nor a CmdContext and a method ignoring \u0026self would misdescribe it.\n\nAC 4 IS UNREACHABLE BY DESIGN, and the reason is sharper than \"the vocabulary is missing\" (improvise-kmgs supplied the vocabulary and it is still unreachable): a keymap binding names an INTENT resolved against CmdContext -- \"move-cursor down\" -- while a Command is the RESOLVED MUTATION -- SetSelected { row, col } -- which only exists once the cursor and layout have been read. So a keymap LOOKUP can never return a Command; only dispatch, after CmdContext, can. That is a property of the Cmd/Command split itself. Forcing it would mean either inventing intent-shaped Command variants (which decision(improvise-cqi, command_set_derived_not_invented) forbids) or dissolving the split.\n\nClosing rather than leaving open: it blocks improvise-cqq and the 35e -\u003e edp -\u003e cr3 chain to improvise-1ey, which is now the priority, and there is no remaining work here that a future ticket would not state better. If dispatch-yields-a-Command is wanted, file it as its own ticket against the real question.","dependencies":[{"issue_id":"improvise-gxi","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-08-29T12:37:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0} -{"_type":"issue","id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.\nGROOMED 2026-09-19: implication (B) in the description is done — improvise-3mm closed with App in improvise-command, which is where this epic wanted it. Implication (A) is recorded as a real edge (cqq depends on gxi), so it no longer needs to live in prose.\nDEFERRED 2026-09-19: the epic closes when improvise-e0u closes, and e0u is deferred by user ruling this session. All of ltq's other children are already closed. Nothing to do here until e0u gets its own campaign.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ltq","title":"Epic: Native TUI as in-process client of the unified server architecture","description":"Restructure the native binary so the ratatui UI is a client of an in-process server, exactly the way the DOM renderer is a client of the service worker in the standalone deployment and of the ws-server in the network deployment. One binary process, two halves: a 'server' holding the full App + projection layer, and a 'client' holding ViewState + render cache + ratatui widgets. Commands flow through a direct in-process channel (same shape as websocket/postMessage protocols, zero serialization overhead). The server emits projection commands back to the client's cache. Result: the ratatui path is architecturally identical to the browser paths — same reduce_full, same Command vocabulary, same cache-based rendering. Widgets stop reading \u0026App directly.","design":"Four deployment modes all share one architecture: (1) native TUI = in-process client + server with direct-call transport, (2) network thin-client = browser main thread + ws-server with websocket transport, (3) standalone web = browser main thread + worker with postMessage transport, (4) hybrid = in-process native client + server PLUS remote ws-server subscriber = native TUI with remote browser observer. Under this epic, mode (1) gets restructured to match (2) and (3). The shared pieces: reduce_full, projection layer with per-client subscriptions, Command vocabulary, render cache shape. The difference per mode is only the transport + which clients subscribe to which projection kinds. Native TUI subscribes to every projection kind because it renders everything (grid, panels, help, wizard); browser thin-client MVP subscribes to grid only. Staging: introduce a temporary RenderCache::from_app adapter, migrate widgets one at a time with the adapter bridging, split the binary and delete the adapter at the end. Every widget migration is individually landable.","notes":"Implications for other epics: (A) improvise-cqq (browser epic projection layer) gains a prerequisite on issue 1 of this epic (unified reduce_full). (B) improvise-3mm (crate-epic step 5) needs its design updated to put App in improvise-command, not improvise-tui, so reduce_full can live alongside App without pulling ratatui into ws-server or worker-server. (C) improvise-cr3 (browser DOM renderer) and issue 3 of this epic (ratatui grid cache consumer) share the RenderCache grid shape and can share the GridViewModel type — coordinate the two to avoid duplication. Stretch issues cover hybrid mode (native TUI + remote browser subscriber) and native undo/replay via command logging.\nGROOMED 2026-09-19: implication (B) in the description is done — improvise-3mm closed with App in improvise-command, which is where this epic wanted it. Implication (A) is recorded as a real edge (cqq depends on gxi), so it no longer needs to live in prose.\nDEFERRED 2026-09-19: the epic closes when improvise-e0u closes, and e0u is deferred by user ruling this session. All of ltq's other children are already closed. Nothing to do here until e0u gets its own campaign.","status":"deferred","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:48:34Z","created_by":"spot","updated_at":"2026-09-20T05:31:01Z","dependencies":[{"issue_id":"improvise-ltq","depends_on_id":"improvise-35e","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-764","type":"blocks","created_at":"2026-04-14T00:53:52Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:49Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:48Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-jb3","type":"blocks","created_at":"2026-04-14T00:53:51Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-n10","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-ltq","depends_on_id":"improvise-pca","type":"blocks","created_at":"2026-04-14T00:53:50Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i34","title":"OPFS-backed Storage implementation for wasm target","description":"Implement the Storage trait (or vfs backend) against the browser's Origin Private File System. Provides read/write/list operations from wasm that work across Chrome, Firefox, and Safari. Falls back to an in-memory or IndexedDB-backed implementation if OPFS is unavailable in the target environment.","design":"Use web-sys to access navigator.storage.getDirectory() and the File System Access API. OPFS is async-only, so either (a) make the Storage trait async-compatible (preferred if we switch to an async trait in the abstraction issue) or (b) wrap OPFS calls in a queued executor so the sync trait API can block on completion via a yield loop. Option (a) is cleaner; the abstraction issue (improvise-6mq) should probably commit to async up front if OPFS is the target. Directory layout: one root directory under OPFS for improvise, with subdirectories per purpose (autosave, user files, session state). File picker integration: when the user says 'open a file' in the browser, use the native file picker and either copy the file into OPFS or open it directly via the File System Access API's handle-based API. Fallback: if OPFS is unavailable (older Safari, some privacy modes), provide an IndexedDB-backed implementation using the idb crate — exposes the same trait.","acceptance_criteria":"(1) Storage implementation for wasm target exists, keyed off a compile-time target check. (2) Round-trips: write bytes, read bytes, list files, delete file. (3) Manual test: load a .improv file via file picker, edit in the DOM renderer, save to OPFS, reload page, file is still there. (4) Unit tests using a memory-backed stub where OPFS is unavailable (CI).","notes":"Depends on improvise-6mq (Storage abstraction must exist first). May reveal a need to change the abstraction from sync to async, in which case update 6mq before starting this.\nUnblocked by improvise-6mq (Storage trait landed 2026-08-29: crates/improvise-io/src/storage.rs, two methods read/write on opaque document paths, PhysicalFs + MemoryFs). NOTE: still not startable in the current dev environment — no wasm32-unknown-unknown std in the Nix toolchain and no rustup. See improvise-ywd notes.\nToolchain unblocked 2026-08-30: wasm32-unknown-unknown added to the flake (improvise-ywd), and all four bundle crates check clean. See improvise-fuj first — the two effects that still call std::fs directly need a decision on how a wasm build injects its Storage backend, which is the same decision this ticket needs.\nSYNC-VS-ASYNC RESOLVED (2026-08-30) — the abstraction does NOT need to become async, so improvise-6mq stands as built.\n\nThe DESIGN here says 'OPFS is async-only' and the NOTES say this 'may reveal a need to change the abstraction from sync to async, in which case update 6mq before starting'. That premise is incomplete. OPFS exposes two APIs:\n\n - the promise-based one (getDirectory, getFileHandle, createWritable) — async\n - FileSystemFileHandle.createSyncAccessHandle() -\u003e FileSystemSyncAccessHandle,\n whose read/write/truncate/flush/close are SYNCHRONOUS, and which is available\n ONLY inside a dedicated Web Worker. It exists precisely so wasm can do\n blocking file I/O; it is the mechanism SQLite's WASM build uses for its OPFS\n VFS.\n\nimprovise-9x6 puts 'the full App + command pipeline + projection layer + VFS persistence inside a browser worker'. So the persistence layer never runs on the main thread, and the sync path is available to it.\n\nShape that falls out:\n - ASYNC, once, at worker init(): navigator.storage.getDirectory(), getFileHandle(), createSyncAccessHandle().\n - SYNC, thereafter: the Storage impl holds the access handles; read/write are ordinary blocking calls, satisfying the existing two-method trait unchanged.\n\nThis avoids an invasive async migration through persistence::save/load and every caller, which is the outcome 6mq's Option-B choice was already hedging toward.\n\nCAVEAT: asserted from API knowledge, not tested — there is no browser here. Confirm createSyncAccessHandle availability against the target browsers (it is the weakest on Safari) before relying on it. The IndexedDB fallback this ticket already calls for is the hedge, and IndexedDB IS async-only — so the fallback, not OPFS, is what would force the async question. Decide whether the fallback is in scope before starting.\n\nPREREQUISITE: improvise-fuj — how a wasm build injects its Storage instance. App holds no Storage today; persistence::save/load hardwire PhysicalFs, and two effects bypass the trait entirely. That plumbing is host-verifiable and should land first.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:34:08Z","created_by":"spot","updated_at":"2026-09-01T06:11:09Z","started_at":"2026-09-01T06:06:19Z","closed_at":"2026-09-01T06:11:09Z","close_reason":"Storage gained list/delete (PhysicalFs + MemoryFs, shared storage_contract test). OpfsFs added for wasm32 using sync access handles, keeping the trait synchronous — createSyncAccessHandle's read/write/truncate/flush/close are blocking, worker-only, which suits improvise-9x6. Async work is four explicit methods: open/attach/refresh/commit_deletes. delete is deferred (index updated immediately, removeEntry on commit). No IndexedDB fallback per user decision — verified Safari 16.4+ has sync handle methods, so it isn't needed. AC(1)(2)(4) met; AC(3) is a browser test that cannot run here, tracked as improvise-xf1 and blocking improvise-9x6.","dependencies":[{"issue_id":"improvise-i34","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:10Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-i34","depends_on_id":"improvise-fuj","type":"blocks","created_at":"2026-08-30T09:57:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-ywd","title":"Wasm compatibility audit and fixes across core/formula/command/io","description":"Verify every crate that belongs in the wasm bundle builds for wasm32-unknown-unknown and runs correctly. Fix anything that doesn't: feature-gate wall-clock time, swap Instant for web_time, enable chrono wasmbind, isolate dirs crate usage, verify pest/serde/indexmap/flate2/enum_dispatch all compile. Ship a cargo-check invocation per crate as a CI gate.","design":"Systematic audit: for each crate (improvise-core, improvise-formula, improvise-command, improvise-io-persistence), run cargo check --target wasm32-unknown-unknown and fix what breaks. Known hazards: (1) dirs crate — not wasm-compatible, isolate behind a trait function. (2) chrono::Local::now() — needs wasmbind feature, used in import analyzer for date parsing. (3) std::time::Instant — swap for web_time crate which falls back to js performance.now() in wasm. (4) std::fs in persistence — handled by the VFS abstraction issue. (5) std::env and std::process — probably none in core, verify. (6) flate2 — verify rust_backend feature works in wasm (should). Strategy: don't feature-gate everything individually, instead do the minimal isolation needed so each crate's public API is portable, and let the downstream bundle crate (improvise-web-standalone) opt in to what it actually needs.","acceptance_criteria":"(1) cargo check --target wasm32-unknown-unknown -p improvise-core passes. (2) Same for improvise-formula, improvise-command, and improvise-io (or improvise-persistence if split). (3) No new feature flags required to get these to build — the core path is wasm-compatible by default. (4) CI job added that runs the wasm checks on every push.","notes":"Depends on the full crate split (epics 1-5) being done — the audit needs each crate to exist independently. Does not depend on the VFS abstraction issue (std::fs concerns are handled there). These two can proceed in parallel.\nTOOLCHAIN-BLOCKED in this dev environment (checked 2026-08-29). The deliverable is 'cargo check --target wasm32-unknown-unknown' per crate plus a CI gate, but the Nix toolchain here ships no wasm32 std and there is no rustup to add it:\n\n $ cargo check -p improvise-core --target wasm32-unknown-unknown\n error[E0463]: can't find crate for 'std'\n note: the wasm32-unknown-unknown target may not be installed\n $ which rustup -\u003e not found\n\nSource changes could be written speculatively (Instant -\u003e web_time, chrono wasmbind, isolating dirs) but none could be VERIFIED, which is the whole point of an audit. Adding wasm32-unknown-unknown to the flake's rust toolchain is the actual first step of this ticket.\n\nSame constraint hits improvise-cqi acceptance criterion 5 ('builds for both host target and wasm32-unknown-unknown').","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:49Z","created_by":"spot","updated_at":"2026-08-30T16:51:24Z","started_at":"2026-08-30T16:47:28Z","closed_at":"2026-08-30T16:51:24Z","close_reason":"AC 1-3 turned out to need NO source changes; the blocker was the toolchain, exactly as the notes I left on 2026-08-29 predicted.\n\nAdded targets = [\"wasm32-unknown-unknown\"] to the flake's rustToolchain (flake.nix:26) and verified wasm32 std lands in the sysroot. Then all four bundle crates check clean, first try, with no feature flags:\n\n improvise-core ok\n improvise-formula ok\n improvise-io ok\n improvise-command ok\n\nThat satisfies AC 1, AC 2 and AC 3 (AC 3 explicitly: 'no new feature flags required — the core path is wasm-compatible by default'). The root improvise crate does NOT build for wasm because crossterm does not; that is correct — it is the terminal binary, not part of the bundle — so the CI job checks the four bundle crates by name rather than the workspace.\n\nAC 4: .github/workflows/ci.yml adds a wasm job plus a host job (fmt, clippy -D warnings, test). There was no test/lint CI before this — only cargo-dist's release.yml and the Claude bots. Verified clippy -D warnings and fmt pass locally so the job is not red on arrival, and validated the workflow YAML parses.\n\nTHE AUDIT'S REAL VALUE WAS THE HAZARD LIST, because a cargo check gate cannot catch anything that merely panics at runtime. Going through the DESIGN section's named hazards one by one:\n\n (1) dirs NON-ISSUE — declared at root Cargo.toml:36, used nowhere (already noted on improvise-v9w)\n (2) chrono Local::now NON-ISSUE — never called\n (3) std::time::Instant REAL — App::last_autosave; Instant::now() panics on wasm32-unknown-unknown yet compiles fine. FIXED: improvise-command now uses web-time, which re-exports std::time off-wasm and performance.now() on it. Verified both targets build and all 14 native suites pass.\n (4) std::fs REAL, NOT FIXED — StartImportWizard (effect.rs:931) and LoadModel (effect.rs:1052) still call std::fs::read_to_string directly, bypassing the Storage trait from improvise-6mq. Compiles on wasm, fails in a browser. Filed as improvise-fuj, because the fix needs a decision on how a wasm build injects its Storage backend — which also determines how improvise-i34 plugs in, so it should not be guessed at here.\n (5) std::env/process NON-ISSUE — std::env appears only in a test\n (6) flate2 NON-ISSUE — compiles for wasm as-is\n\nCaveat worth stating plainly: 'compiles for wasm32' is all that has been verified. Nothing here has been RUN on a wasm host — there is no browser or wasm runtime in this environment — so the web-time fix is verified by dual-target compilation and unchanged native behaviour, not by execution. Actual runtime verification arrives with improvise-i34 / improvise-tm6.","dependencies":[{"issue_id":"improvise-ywd","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:40:09Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-6mq","title":"Introduce Storage/VFS abstraction in persistence layer","description":"Refactor persistence/mod.rs to go through a Storage trait instead of std::fs directly. Native build wires PhysicalFS; later issues wire OPFS for wasm. Behavior-preserving refactor — .improv files still save and load identically on native. Foundation for the standalone web deployment and for alternative formats (sqlite, parquet) that can target any backend.","design":"Option A: adopt the vfs crate directly (vfs::VfsPath, vfs::PhysicalFS). Pros: batteries included, filesystem-path-based API, open-source implementations. Cons: sync-only; wasm backends may need async. Option B: define our own narrow Storage trait with read_bytes/write_bytes/list/stat methods, sync for native and async-wrapped for wasm. Pros: tailored to our needs. Cons: more code to maintain. Recommend starting with Option A (vfs crate) and falling back to Option B if OPFS's async-only nature forces the issue. Refactor persistence::{save_md, load_md, save_json, load_json} to take \u0026dyn Storage (or VfsPath) as the target parameter. Autosave path resolution (currently uses the dirs crate) goes behind the abstraction too — native resolves to a dirs path, wasm resolves to a fixed OPFS path.","acceptance_criteria":"(1) Persistence functions take a storage parameter rather than reading/writing files directly. (2) Native build wires PhysicalFS (or equivalent) and all existing persistence tests pass unchanged. (3) dirs crate usage is isolated behind a function that can be stubbed for wasm. (4) Round-trip tests still pass. (5) A memory-backed test fixture exists for unit testing persistence without touching disk.","notes":"Depends on crate-split epic step 3 (improvise-8zh — improvise-io extraction) so the refactor happens in the right crate. Valuable independently of wasm: enables testing persistence without tempfile, simplifies fuzzing.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:33:47Z","created_by":"spot","updated_at":"2026-08-29T19:40:58Z","started_at":"2026-08-29T19:37:37Z","closed_at":"2026-08-29T19:40:58Z","close_reason":"persistence::save/load now go through a Storage trait; save/load remain thin PhysicalFs wrappers so every caller and all 83 persistence tests are untouched (AC 2).\n\nCHOSE OPTION B OVER THE RECOMMENDED OPTION A. The design section recommends starting with the vfs crate. I measured the actual surface first: persistence touches the filesystem in exactly two places — write the whole document in save, read the whole document in load. autosave_path is pure path manipulation with no I/O. That does not justify a dependency plus a VfsPath abstraction. The ticket's own con for Option A is decisive: it is sync-only while OPFS, the actual wasm target, is async — adopting A would mean doing B later anyway. The trait is two methods:\n\n fn read(\u0026self, path: \u0026Path) -\u003e Result\u003cVec\u003cu8\u003e\u003e\n fn write(\u0026self, path: \u0026Path, bytes: \u0026[u8]) -\u003e Result\u003c()\u003e\n\nPaths are opaque document names, not a directory tree, so an OPFS or map-backed backend implements it without pretending to have directories, metadata or seeking.\n\nAC 3 IS VACUOUS: 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage. dirs = \"6\" is declared at root Cargo.toml:36 and referenced nowhere under src/ or crates/*/src/. It is simply an unused dependency; noted on improvise-v9w, which already covers removing unused root deps.\n\nGzip moved from streaming File+GzEncoder to encode-then-write. The .gz suffix still selects it, which makes gzip a property of the document name rather than of the backend. physical_and_memory_backends_write_identical_bytes pins that swapping backends does not change the bytes.\n\nAC 5 (memory-backed fixture): MemoryFs, plus three persistence tests that round-trip a workbook with categories, items and data through it — no tempfile, no disk. That is the shape a browser backend plugs into.\n\nVerified beyond unit tests: import to .improv, headless add-cat against it, and the .gz path all still work through the CLI.","dependencies":[{"issue_id":"improvise-6mq","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-14T00:40:08Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":5,"comment_count":0} {"_type":"issue","id":"improvise-rbv","title":"Include _Measure in _Dim columns for records mode","description":"In records mode, _Dim columns are generated from regular (non-virtual) categories plus a synthetic 'Value' column. _Measure is excluded because it starts with '_'. This means records show a generic 'Value' column instead of showing which measure (Revenue, Cost, etc.) each record belongs to. _Measure items should appear as _Dim columns in records mode so the measure name is visible per-record.","status":"closed","priority":2,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:40Z","created_by":"spot","updated_at":"2026-04-15T11:16:12Z","closed_at":"2026-04-15T11:16:12Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dz8","title":"Add Axis::Filter for drill-fixed coordinates","description":"Drill mode sets fixed coordinates (e.g. Product=Widgets, Region=North) as Axis::Page, but these should be immutable constraints, not user-navigable page dimensions. Add Axis::Filter variant that: (1) excludes categories from tile bar in tile select mode, (2) excludes from page cycling via [ and ], (3) visually distinguished from Page in the UI. Drill uses Filter instead of Page for its fixed coordinates.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:30:04Z","created_by":"spot","updated_at":"2026-09-01T05:45:01Z","started_at":"2026-09-01T05:38:46Z","closed_at":"2026-09-01T05:45:01Z","close_reason":"Axis::Filter added. DrillIntoCell fixes coordinates on Filter instead of Page. All three ticket requirements met: excluded from tile select (View::tileable_cats, used by tile_bar + MoveTileCursor + TileAxisOp), excluded from [/] page cycling (page_cat_data stays on categories_on(Axis::Page)), and visually distinct (Fix ⚲, yellow, via Axis::color -\u003e AxisColor::Filter). Data filtering preserved via View::filtering_cats (Page ∪ Filter), used by GridLayout and add-record-row. Axis::next maps Filter to itself so nothing can rotate into it. .improv grammar gains a 'filter' keyword with round-trip test — defensive, since Filter only occurs in the never-persisted _Drill view. Note: adding the variant caused exactly ONE compile error outside axis.rs, because improvise-rml had already consolidated the display matches.","dependencies":[{"issue_id":"improvise-dz8","depends_on_id":"improvise-rml","type":"blocks","created_at":"2026-04-16T16:02:39Z","created_by":"Ed L","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at examples/demo.improv or web/demo.improv.\n\n(Design field corrected 2026-09-19: it named bank-info.improv, which PR #22 deleted as unloadable -- hand-damaged during the v2025-04-09 migration and unreadable since April. roadmap.org line 194 is generated from this field, so the fix belongs here and the file is regenerated, never hand-edited. Note 'no auth, no TLS' is still an accurate description of improvise-ws-server and is now tracked as improvise-kpnr and improvise-kc7n rather than being merely a scope note.)","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.\nGROOMED 2026-09-20 after wave 1. The acceptance criteria are met and the evidence is better than the one the ticket asked for.\n\nCriterion (7) was 'video or screenshot of the demo attached to the issue'. What exists instead: crates/improvise-wasm-client/tests/browser_round_trip.rs drives the client's own serialized frames through a REAL Host over a REAL App, and harness/tests/shell.spec.js renders web/app.js under Chromium and asserts the grid draws, a header reads Revenue, an editable cell exists, and click-to-edit commits and shows the host's value. A screenshot proves a moment; these prove the path, in CI.\n\n(1)-(4) and (6) are covered by those two. (5), 'scrolling fetches cells for the new viewport', is covered in principle by Client::set_viewport and the GridExtent work in PR #23, but NOT by an executed test -- web/demo.improv is too small to scroll (improvise-plcx).\n\nThe design field's reference to bank-info.improv was corrected 2026-09-20; that file was deleted as unloadable (improvise-odbc) and roadmap.org:194 is generated from this field.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6, criterion by criterion.\nConfirms the earlier annotation on (5) and SHARPENS (2) and (6), which it\nrecorded as met by evidence that does not in fact reach them.\n\n(1) ws-server SERVES A FILE FROM A CLI ARG AND ACCEPTS A WEBSOCKET — MET.\n crates/improvise-ws-server/tests/socket.rs is the one test on a real\n socket: handshake, JSON framing, SessionId routing and which document a URL\n asked for. crates/improvise-ws-server/src/main.rs is the clap entry.\n\n(2) BROWSER LOADS THE PAGE, WASM INITIALISES, GRID RENDERS — MET, but note\n WHICH DEPLOYMENT. harness/tests/shell.spec.js's \"the page draws the demo\n document out of the worker\" runs web/app.js under Chromium and asserts a\n table, a \"Revenue\" header and an editable cell — over the IN-PAGE WORKER,\n not over a ws-server. The four `?ws=` specs use mocked peers\n (`routeWebSocket`), so no test in this repository points a real browser at\n a real improvise-ws-server. Since improvise-xks6 both deployments speak one\n frame pair and `Host::handle` is one implementation, so the untested\n difference is the transport alone — but that is an argument, not a run, and\n the earlier annotation did not say so.\n\n(3) ARROW KEYS MOVE THE CURSOR LOCALLY WITH NO ROUND-TRIP — MET.\n crates/improvise-wasm-client/tests/lockstep.rs::\n a_locally_resolved_cursor_move_lands_where_the_host_would_put_it, plus\n a_locally_resolved_jump_to_the_last_row_agrees_with_the_host and\n a_client_with_no_window_answers_no_cursor_key for the edges. `resolve`\n returns KeyOutcome::Local, which is by construction the no-host path.\n\n(4) TYPING A NUMBER + ENTER ROUND-TRIPS AND THE NEW VALUE APPEARS — MET twice,\n at two levels. crates/improvise-wasm-client/tests/browser_round_trip.rs::\n a_browser_client_seeds_types_a_number_and_sees_it_come_back drives the\n client's own serialized frames through a real Host over a real App;\n shell.spec.js's \"committing an edit sends one frame and shows the host's\n value\" does it in Chromium and asserts the HOST's formatting (\"20,000\"),\n not the string typed.\n\n(5) SCROLLING FETCHES CELLS FOR THE NEW VIEWPORT — NOT MET BY AN EXECUTED\n TEST, and this is what keeps the ticket open. browser_round_trip.rs::\n a_resize_reprojects_the_grid_at_the_new_extent covers a RESIZE, which is\n the extent changing at a fixed offset; scrolling is the offset moving,\n which is where gotcha 47's \"the window is the client's own fact\" and the\n GridExtent arithmetic actually bite. web/demo.improv is too small to\n scroll, so the fixture is the blocker: improvise-plcx, an open dependency\n of this ticket, is what closes it.\n\n(6) RELOAD RECOVERS CLEANLY FROM CURRENT SERVER STATE — PARTIALLY MET, and\n less than the earlier annotation claimed. What is tested is the SEED: a\n fresh client's handshake and Snapshot, in browser_round_trip.rs. What is\n not tested anywhere is a browser actually reloading and coming back onto a\n document that has moved since — the reload half of the OPFS story is\n covered at the STORAGE layer by harness/tests/opfs.spec.js, which drives\n the storage contract inside a real Worker and never goes through app.js.\n\n(7) VIDEO OR SCREENSHOT ATTACHED TO THE ISSUE — NOT MEETABLE AS WRITTEN (bd\n has no attachments) and superseded, on the same argument as improvise-bck's\n criterion 4.\n\nSO: this ticket's remaining content is (5) via improvise-plcx. (2) and (6) are\nmet well enough to ship and their gaps are named above rather than rounded off.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-09-20T18:02:18Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-08-30T10:08:33Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:20:27Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":4,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-1ey","title":"Browser frontend MVP: end-to-end working demo","description":"Wire everything together into a working browser demo: start the ws-server, open the HTML shell in a browser, it loads the wasm client, connects to the websocket, receives the initial snapshot, renders the grid, accepts keystrokes, and round-trips commands to the server. This is the milestone where the architecture proves itself end-to-end. Scope: open an existing .improv file server-side, view it in the browser, type a number into a cell, see the server-side value update and the browser's projection arrive with the new value.","design":"HTML shell: minimal index.html with a \u003cdiv\u003e for the grid and a \u003cscript\u003e tag bootstrapping the wasm module. JS bootstrap: loads wasm, opens websocket connection, wires on_message from the socket into wasm.on_message, wires wasm.on_key_event output into the socket send. Reconnection: on socket close, show a status indicator; on reconnect, request a fresh snapshot. No auth, no TLS, localhost only. Test by pointing at examples/demo.improv or web/demo.improv.\n\n(Design field corrected 2026-09-19: it named bank-info.improv, which PR #22 deleted as unloadable -- hand-damaged during the v2025-04-09 migration and unreadable since April. roadmap.org line 194 is generated from this field, so the fix belongs here and the file is regenerated, never hand-edited. Note 'no auth, no TLS' is still an accurate description of improvise-ws-server and is now tracked as improvise-kpnr and improvise-kc7n rather than being merely a scope note.)","acceptance_criteria":"(1) ws-server serves a file from CLI arg, accepts a websocket connection. (2) Browser loads page, wasm initializes, grid renders. (3) Arrow keys move the cursor locally (view effect) with no round-trip. (4) Typing a number + Enter in a cell round-trips to the server and the new value appears. (5) Scrolling fetches cells for the new viewport. (6) Reload recovers cleanly from current server state. (7) Video or screenshot of the demo attached to the issue.","notes":"Final milestone of the browser epic. Depends on projection layer (improvise-cqq), ws-server (improvise-q08), wasm client (wasm-client id), DOM renderer (dom-renderer id). No new logic — just glue and testing.\nGROOMED 2026-09-20 after wave 1. The acceptance criteria are met and the evidence is better than the one the ticket asked for.\n\nCriterion (7) was 'video or screenshot of the demo attached to the issue'. What exists instead: crates/improvise-wasm-client/tests/browser_round_trip.rs drives the client's own serialized frames through a REAL Host over a REAL App, and harness/tests/shell.spec.js renders web/app.js under Chromium and asserts the grid draws, a header reads Revenue, an editable cell exists, and click-to-edit commits and shows the host's value. A screenshot proves a moment; these prove the path, in CI.\n\n(1)-(4) and (6) are covered by those two. (5), 'scrolling fetches cells for the new viewport', is covered in principle by Client::set_viewport and the GridExtent work in PR #23, but NOT by an executed test -- web/demo.improv is too small to scroll (improvise-plcx).\n\nThe design field's reference to bank-info.improv was corrected 2026-09-20; that file was deleted as unloadable (improvise-odbc) and roadmap.org:194 is generated from this field.\nVERIFICATION PASS, wave 2 WU-P, 2026-09-20 at 52507a6, criterion by criterion.\nConfirms the earlier annotation on (5) and SHARPENS (2) and (6), which it\nrecorded as met by evidence that does not in fact reach them.\n\n(1) ws-server SERVES A FILE FROM A CLI ARG AND ACCEPTS A WEBSOCKET — MET.\n crates/improvise-ws-server/tests/socket.rs is the one test on a real\n socket: handshake, JSON framing, SessionId routing and which document a URL\n asked for. crates/improvise-ws-server/src/main.rs is the clap entry.\n\n(2) BROWSER LOADS THE PAGE, WASM INITIALISES, GRID RENDERS — MET, but note\n WHICH DEPLOYMENT. harness/tests/shell.spec.js's \"the page draws the demo\n document out of the worker\" runs web/app.js under Chromium and asserts a\n table, a \"Revenue\" header and an editable cell — over the IN-PAGE WORKER,\n not over a ws-server. The four `?ws=` specs use mocked peers\n (`routeWebSocket`), so no test in this repository points a real browser at\n a real improvise-ws-server. Since improvise-xks6 both deployments speak one\n frame pair and `Host::handle` is one implementation, so the untested\n difference is the transport alone — but that is an argument, not a run, and\n the earlier annotation did not say so.\n\n(3) ARROW KEYS MOVE THE CURSOR LOCALLY WITH NO ROUND-TRIP — MET.\n crates/improvise-wasm-client/tests/lockstep.rs::\n a_locally_resolved_cursor_move_lands_where_the_host_would_put_it, plus\n a_locally_resolved_jump_to_the_last_row_agrees_with_the_host and\n a_client_with_no_window_answers_no_cursor_key for the edges. `resolve`\n returns KeyOutcome::Local, which is by construction the no-host path.\n\n(4) TYPING A NUMBER + ENTER ROUND-TRIPS AND THE NEW VALUE APPEARS — MET twice,\n at two levels. crates/improvise-wasm-client/tests/browser_round_trip.rs::\n a_browser_client_seeds_types_a_number_and_sees_it_come_back drives the\n client's own serialized frames through a real Host over a real App;\n shell.spec.js's \"committing an edit sends one frame and shows the host's\n value\" does it in Chromium and asserts the HOST's formatting (\"20,000\"),\n not the string typed.\n\n(5) SCROLLING FETCHES CELLS FOR THE NEW VIEWPORT — NOT MET BY AN EXECUTED\n TEST, and this is what keeps the ticket open. browser_round_trip.rs::\n a_resize_reprojects_the_grid_at_the_new_extent covers a RESIZE, which is\n the extent changing at a fixed offset; scrolling is the offset moving,\n which is where gotcha 47's \"the window is the client's own fact\" and the\n GridExtent arithmetic actually bite. web/demo.improv is too small to\n scroll, so the fixture is the blocker: improvise-plcx, an open dependency\n of this ticket, is what closes it.\n\n(6) RELOAD RECOVERS CLEANLY FROM CURRENT SERVER STATE — PARTIALLY MET, and\n less than the earlier annotation claimed. What is tested is the SEED: a\n fresh client's handshake and Snapshot, in browser_round_trip.rs. What is\n not tested anywhere is a browser actually reloading and coming back onto a\n document that has moved since — the reload half of the OPFS story is\n covered at the STORAGE layer by harness/tests/opfs.spec.js, which drives\n the storage contract inside a real Worker and never goes through app.js.\n\n(7) VIDEO OR SCREENSHOT ATTACHED TO THE ISSUE — NOT MEETABLE AS WRITTEN (bd\n has no attachments) and superseded, on the same argument as improvise-bck's\n criterion 4.\n\nSO: this ticket's remaining content is (5) via improvise-plcx. (2) and (6) are\nmet well enough to ship and their gaps are named above rather than rounded off.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:44Z","created_by":"spot","updated_at":"2026-09-20T18:02:18Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-1ey","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:07:38Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-08-30T10:08:33Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-plcx","type":"blocks","created_at":"2026-09-20T16:20:27Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-1ey","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":4,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cr3","title":"DOM renderer for browser grid (reads ViewState + render cache)","description":"JS/TS (or Rust via web-sys) layer that subscribes to the wasm client's state and renders the grid to the DOM. MVP: rebuild a \u003ctable\u003e element on each state change, no virtual-DOM diffing. Reads ViewState for mode indicator, cursor highlight, minibuffer text; reads render cache for cell contents, labels, column widths. Captures browser keyboard events and routes them into the wasm client's on_key_event.","design":"Option A: pure JS/TS module that reads wasm-exposed state via JsValue and updates DOM imperatively. Simpler for MVP. Option B: Rust + web-sys in the wasm crate, rendering from inside wasm. More code sharing but bigger bundle. Start with Option A. Renderer: single \u003ctable\u003e for the grid body, \u003cthead\u003e for column labels, \u003ctbody\u003e with rows. On state change, re-render the affected sections. Cursor highlight is a CSS class on the selected \u003ctd\u003e. Mode indicator is a \u003cdiv\u003e above the table. Minibuffer is a \u003cdiv\u003e shown conditionally when mode is Editing/FormulaEdit/etc.","acceptance_criteria":"(1) Grid renders from a ViewState + RenderCache snapshot. (2) Cursor highlight updates on cursor move without full re-render (nice to have, not required for MVP). (3) Mode indicator reflects current AppMode. (4) Keyboard events on document are captured and routed to wasm on_key_event. (5) Works in Chrome and Firefox. Help overlay, panels, import wizard, tile bar — all deferred post-MVP.","notes":"Consumes viewmodels (not the render cache directly) per improvise-edp. Specifically GridViewModel for grid rendering. Shares GridViewModel type and compute_grid_viewmodel function with ratatui's grid widget (improvise-n10) — one derivation, two rendering backends. DOM-specific concerns (device pixel ratio, CSS class names) live in the RenderEnv the viewmodel is computed against, not in the viewmodel itself.\nPRIORITY RAISED P3-\u003eP2 (groomed 2026-08-30): this gates P2 work (improvise-1ey improvise-6jk improvise-djm ), so leaving it P3 made 'bd ready' unworkable in priority order — the P2 cannot be reached without it. Priority should be non-decreasing along dependency edges. Revert if the intent was that the downstream P2 is genuinely not wanted yet.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:41Z","created_by":"spot","updated_at":"2026-09-20T00:37:49Z","closed_at":"2026-09-20T00:37:49Z","close_reason":"Landed as ad5d338 (merge of c72832f). 1090 tests passing, clippy/fmt clean, wasm clean on all six browser-bundle crates.\n\nWHAT IT IS: render.rs builds a RenderModel in which every question with a SILENT wrong answer is already decided in Rust under test, because the DOM half (web/app.js) is untested JavaScript -- editable is a fact about the CellId variant (Record only, per ADR 002; None is 'no projected cell', NOT 'aggregate'); the selection is placed by SUBTRACTING the scroll offset, because GridCache is viewport-indexed while ViewState::selected is absolute; the category panel is viewmodel::flatten_cat_tree, never a second walk. The page is four string calls: viewport, on_frame, render_model, typed_cell.\n\nVERIFIED HEADLESS END TO END with the real artifact: the built wasm module driven from Node over a real websocket to a real improvise-ws-server -- seed, render, type 98765, the value moves server-side, the projection comes back formatted as 98,765. That is improvise-1ey acceptance (4) with only the DOM missing.\n\nNOT VERIFIED, stated plainly: no browser and no headless DOM was available. web/app.js is syntax-checked and every call it makes is confirmed present in the generated .d.ts with the right arity, but NO PIXEL OF IT HAS BEEN RENDERED -- click-to-edit, the ResizeObserver extent arithmetic and the CSS are unexercised. That is what remains of 1ey along with keyboard nav (improvise-bpq2: ClientMsg has no Key variant, so a forwarded keypress is silently skipped today).\n\nFOLLOW-UPS FILED: improvise-zv4m/xks6 (protocol should own the framing -- socket.rs mirrors ws-server's enums because a native crate cannot be a wasm dependency), improvise-odbc (bank-info.improv does not parse, which matters because 1ey names it as the demo file).","dependencies":[{"issue_id":"improvise-cr3","depends_on_id":"improvise-edp","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cr3","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:42Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-gsw","title":"improvise-wasm-client crate (keymap + reduce_view in wasm)","description":"Create the wasm crate that runs in the browser. Depends on improvise-protocol (Command, ViewState, reduce_view) and on the extracted improvise-command crate for the keymap. Exposes wasm-bindgen entry points: init, on_key_event, on_message (incoming command from server), current_view_state (for the DOM renderer to read). Resolves browser keyboard events locally via the keymap, dispatches the resulting command through reduce_view, serializes the command for sending upstream.","design":"crates/improvise-wasm-client/. wasm-bindgen + web-sys. Entry points: #[wasm_bindgen] fn init() -\u003e Handle (allocates ViewState + RenderCache); fn on_key_event(handle, browser_key_event) -\u003e Option\u003cString\u003e (resolves via keymap, dispatches via reduce_view, returns serialized Command to send upstream or None if no-op); fn on_message(handle, serialized_command) (deserializes incoming Command, applies via reduce_view, marks render dirty); fn render_state(handle) -\u003e JsValue (exposes ViewState + RenderCache for the DOM renderer). Key conversion: browser KeyboardEvent → improvise neutral Key enum via a From impl. The keymap is the same Keymap type used server-side, but with no ratatui dependency (from improvise-command after crate epic step 5).","acceptance_criteria":"(1) Crate compiles to wasm32-unknown-unknown. (2) Size budget: under 500 KB compressed. (3) on_key_event correctly resolves common keys (arrows, letters, Enter, Esc) via the keymap and dispatches through reduce_view. (4) on_message correctly deserializes and applies projection commands. (5) A small JS test harness can drive the crate end-to-end without a real websocket.","notes":"Transport-agnostic at the Rust layer: on_message accepts serialized commands regardless of source, and outgoing commands are returned to the JS bootstrap which routes them to a WebSocket (thin-client epic 6jk) or a worker MessageChannel (standalone epic tm6). The same wasm artifact is reused literally in both deployments — only the JS bootstrap differs. Depends on improvise-cqi (protocol crate) and crate-epic step 5 (improvise-3mm) so Keymap is importable without ratatui. Does not include the DOM renderer itself — that is improvise-cr3, which consumes the state this crate exposes.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:24:37Z","created_by":"spot","updated_at":"2026-09-19T23:40:23Z","closed_at":"2026-09-19T23:40:23Z","close_reason":"crates/improvise-wasm-client landed complete, not a skeleton. 28 tests; cargo check -p improvise-wasm-client --target wasm32-unknown-unknown clean.\n\nTHE TICKET AC 3 IS A FALSE PREMISE and the design turns on it: a browser client resolves almost NO keys locally. A binding names an INTENT resolved against a CmdContext (Model, GridLayout, cursor, yanked cell) while a Command is the RESOLVED MUTATION -- the same reason improvise-gxi AC 4 was closed. So keys travel upstream UNRESOLVED as ClientMessage::Key { code, modifiers }.\n\nTHE ONE LOCAL PATH is minibuffer text entry, allowed for a STRUCTURAL reason rather than convenience: the target buffer is AppMode::minibuffer().buffer_key, the same field TextEntry::new reads (gotcha 11), so the two rules are DERIVED FROM ONE FACT rather than duplicated. Four things were considered and rejected with reasons in the module docs: cursor movement (GridCache is viewport-indexed, so a client cannot tell last-visible-row from last-row and cannot clamp), panel cursors (the flattening depends on expanded_cats), any modified character (KeyPattern::AnyChar matches under ANY modifier on the host, so mirroring the fallback would be a SUPERSET -- local resolution may only ever be a strict subset), and CommandMode Backspace (the single TextEntry::rebind, which leaves the mode on an empty buffer).\n\nimprovise-command is a DEV-dependency only, so the browser graph is formula -\u003e core -\u003e protocol -\u003e client: no App, Workbook, persistence or wizard. tests/lockstep.rs drives the real host and the client over the same keypress across all seven text-entry modes.\n\nAC 2 (under 500KB compressed) UNVERIFIED -- no wasm-pack/wasm-opt available; the dependency graph is the strongest evidence here and someone should measure a real release build. AC 5 (JS harness) not written, and belongs with improvise-cr3.","dependencies":[{"issue_id":"improvise-gsw","depends_on_id":"improvise-3mm","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-gsw","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:41Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-q08","title":"improvise-ws-server binary (tokio + tungstenite session wrapper)","description":"New binary crate that wraps today's App behind a websocket. Accepts connections, creates a ClientSession per connection, receives Command messages from clients, dispatches them into the App, drains the outbound projection queue, sends projection commands back down. One App instance per session for the MVP (no shared authoritative state across sessions yet).","design":"crates/improvise-ws-server/. Tokio runtime, tokio-tungstenite for websocket. On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport). Main loop: recv Command from client → feed into reduce_full (today's App::handle_key path, but command-keyed instead of key-keyed) → drain projection queue → send Commands down. On disconnect: drop session. Single session per connection for MVP. No auth, no collab, localhost only. Wire format: serde_json to start (easy to debug), bincode/postcard later if size matters.","acceptance_criteria":"(1) Binary crate builds and accepts websocket connections on a configurable port. (2) Initial snapshot is sent on connect. (3) Command round-trip works: client Command in → model updated → projection Command out. (4) Disconnection cleanly drops session state. (5) Integration test: spawn server, connect a test client, send a SetCell, verify a CellsUpdated comes back.","notes":"Depends on improvise-protocol (wire types) and improvise-mae/vb4/projection-emission for the server-side logic. The server itself is a thin transport wrapper — all the real logic lives in the App and projection layer.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:14Z","created_by":"spot","updated_at":"2026-09-19T23:46:08Z","closed_at":"2026-09-19T23:46:08Z","close_reason":"crates/improvise-ws-server landed whole; all five ACs met and tested. 14 tests (13 in host.rs + 1 real-socket integration). Host::handle is where every decision is made and every unit test points; transport.rs is framing, JSON and one handshake rule, with a doc comment saying that if the socket test ever becomes the place a behaviour is pinned, that behaviour belongs in host.rs.\n\nONE APP PER DOCUMENT, as ruled. Not per connection (the ticket premise, now false -- Sessions exists to hold many ClientSessions against one App, and per-connection Apps would make broadcast, apply_switch and apply_model dead code and give two tabs on one document two documents) and not per server. No singleton: Host is a value the transport is handed. The multi-document registry slots into transport.rs where host_loop is -- a HashMap\u003cDocumentId, Host\u003e plus a document id on Connect -- and nothing above it changes, because Sessions never owned the App.\n\nTHE ONLY ADDITION TO THE SHARED VOCABULARY IS ClientMsg::Viewport { rows, cols }, and it is derived rather than invented: the extent is the one session input no Command carries by construction. It is also the HANDSHAKE -- the bootstrap Snapshot seed carries the VISIBLE cells, so a snapshot built at extent zero is an empty grid needing an immediate second round trip. That rule lives in transport::connection alone, which keeps Host::connect total.\n\nNO KEYPRESS DOOR: the retraction arrived before one was built, so nothing had to be removed. ClientMsg carries Viewport and Command only.\n\nEvery non-obvious behaviour proven by injection, including both arms of the broadcast (relay to nobody FAILED; relay to everyone FAILED). The document-swap fixture keeps the incoming active view named Default per gotcha 39 fixture warning, so it cannot pass by accident.","dependencies":[{"issue_id":"improvise-q08","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:39Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-q08","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":3,"comment_count":0} @@ -410,7 +488,7 @@ {"_type":"issue","id":"improvise-cqi","title":"Extract improvise-protocol crate (Command, ViewState, reduce_view)","description":"Create a new crate that holds the types shared between the server and the wasm client: Command enum (the wire format), ViewState struct, render cache types (CellDisplay, visible region), and the reduce_view function. Depends on improvise-core so it can reference CellKey, CellValue, AppMode. Serde on everything that crosses the wire. This is the load-bearing crate for the browser epic.","design":"pub enum Command { /* user-initiated */ MoveCursor(Delta), EnterMode(AppMode), AppendMinibufferChar(char), ScrollTo { row, col }, SetCell { key: CellKey, value: CellValue }, AddCategory(String), ... /* server→client projections */ CellsUpdated { changes: Vec\u003c(CellKey, CellDisplay)\u003e }, ColumnLabelsChanged { labels: Vec\u003cString\u003e }, ColumnWidthsChanged { widths: Vec\u003cu16\u003e }, RowLabelsChanged { labels: Vec\u003cString\u003e }, ViewportInvalidated, ... }. pub fn reduce_view(vs: \u0026mut ViewState, cache: \u0026mut RenderCache, cmd: \u0026Command) — pattern-matches the command and applies view-slice effects + render-cache updates. Client imports this directly. Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state for projection computation.","acceptance_criteria":"(1) New crate crates/improvise-protocol/ exists. (2) Depends only on improvise-core, improvise-formula (if needed for anything), and serde. No ratatui, no crossterm. (3) Command, ViewState, RenderCache, CellDisplay all implement Serialize+Deserialize. (4) reduce_view has unit tests for view effect application and cache updates. (5) Crate builds for both host target and wasm32-unknown-unknown.","notes":"Depends on improvise-vb4 (ViewState must exist as a distinct type first) and on crate-epic step 2 (improvise-36h — improvise-core must be extracted so protocol can depend on it without pulling ratatui).\nNote (2026-08-29): acceptance criterion 5 (builds for wasm32-unknown-unknown) is not verifiable in the current dev environment — no wasm32 std in the Nix toolchain and no rustup. See improvise-ywd notes. The rest of this ticket is host-verifiable.\nCORRECTION (2026-08-29): the DESIGN section's justification for ViewState serialization is wrong, and misleading in a way that would invert the architecture.\n\nIt says: 'Server imports Command + ViewState so it can serialize wire messages and maintain a mirror of each client's view state.' The server does NOT serialize ViewState to maintain the mirror. Per improvise-cqq, it updates each session's view state 'via the shared reduce_view' — i.e. by applying the same commands the client applies. ViewState is a PROJECTION of the command stream, not a wire message. improvise-ltq (commands both directions) and improvise-m91 ('a command log is a deterministic replay') say the same.\n\nViewState: Serialize is still needed, but for exactly one reason: improvise-q08's bootstrap — 'On connect: create App + ClientSession, send initial Snapshot (full ViewState + render cache filled from current viewport)'. A client that never saw the earlier stream needs a value to fold onto. Snapshot-then-stream, not state-on-every-change.\n\nConsequences for whoever picks this up:\n- AC 3 stands, but do not build a server that ships ViewState on change. Steady-state traffic is Commands only.\n- RenderCache is local, rebuilt by applying projection commands; it needs Serialize only to ride in that same bootstrap Snapshot.\n- Because the only serialization point is bootstrap, host-only fields must NOT be in ViewState: a fresh client should not inherit a half-open import wizard, a pending prefix key, or staged drill edits.\n\nBLOCKER FOUND (2026-08-29): ViewState as it stands cannot live in this crate at all. crates/improvise-command/src/ui/app.rs:381 holds transient_keymap: Option\u003cArc\u003cKeymap\u003e\u003e. Keymap is in improvise-command, which will depend on improvise-protocol (improvise-gxi puts reduce_full there, taking \u0026Command) — a hard dependency cycle, independent of serde. wizard: Option\u003cImportWizard\u003e is a softer version (improvise-io is acyclic but outside AC 2's allowlist); drill_state and AppMode simply lack derives.\n\nProposed fix for the hard one, per the maintainer: flip the Arc. The transient keymap is always the sub-keymap of a Binding::Prefix at a specific (mode, key, mods), set during dispatch and consumed on the next keypress. ViewState can hold that address — Option\u003c(KeyCode, KeyModifiers)\u003e — and let KeymapSet, which already owns every keymap, resolve it. Both consumers can: App::handle_key has keymap_set and the mode; draw.rs only needs binding_hints() for the which-key popup. Nothing between set and consume changes the mode, since a Prefix binding emits SetTransientKeymap and nothing else.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:23:10Z","created_by":"spot","updated_at":"2026-09-01T09:03:56Z","started_at":"2026-09-01T08:55:39Z","closed_at":"2026-09-01T09:03:56Z","close_reason":"crates/improvise-protocol created. AC(1) crate exists; AC(2) depends only on improvise-core + serde, verified by cargo tree (no ratatui/crossterm/tokio) and by the CI wasm job; AC(3) Command/ViewState/RenderCache/CellDisplay all Serialize+Deserialize with a per-variant JSON round-trip test; AC(4) reduce_view has 12 tests covering view application, cache updates, out-of-range projections, and determinism over a repeated stream; AC(5) builds for host and wasm32, added to the CI wasm job.\n\nCommand's client-\u003eserver half was derived, not invented: it is the set of ViewOnly effects targeting the portable slice, which improvise-h0b had already separated from host-local state.\n\nOne addition beyond a pure move: ViewState::active_view, because reduce_view runs on a client with no Workbook and cursors is keyed by view name. Two writers only (App::switch_to_view, App::install_workbook), guarded by the_two_active_view_names_never_diverge. Whether the two copies may diverge is improvise-g0f, which now blocks improvise-cqq.\n\nFollowed the 2026-08-29 correction on the ticket: ViewState is a projection of the command stream, serialized only for the bootstrap Snapshot. Nothing here ships ViewState on change.","dependencies":[{"issue_id":"improvise-cqi","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-14T00:25:38Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-a2x","type":"blocks","created_at":"2026-08-30T15:56:13Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-h0b","type":"blocks","created_at":"2026-08-29T20:48:44Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-jc8","type":"blocks","created_at":"2026-08-29T20:44:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-cqi","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:37Z","created_by":"spot","metadata":"{}"}],"dependency_count":5,"dependent_count":6,"comment_count":0} {"_type":"issue","id":"improvise-mae","title":"Tag existing effects as model / view / projection-emitting","description":"Add a classification to every Effect so the server can decide which effects trigger projection-command emission to connected clients. Pure view effects (cursor move, mode change) don't project. Model effects (SetCell, AddCategory, AddFormula) do project — they emit CellsUpdated or similar commands to clients whose viewport includes the change. Prerequisite for the server-side projection emission layer.","design":"Add a method to Effect like fn kind(\u0026self) -\u003e EffectKind where EffectKind is { ModelTouching, ViewOnly }. If Step 4 of crate epic (Effect→enum) has landed, this is a const match on variant. If not, each struct impls a kind() method. ModelTouching effects are further classified by what they change, so projection computation knows what kind of command to emit: { CellData, CategoryShape, ColumnLabels, RowLabels, FormulaResult, LayoutGeometry }.","acceptance_criteria":"(1) Every existing Effect has a kind classification. (2) A const/fn on Effect returns the kind without running apply. (3) Unit tests verify classification for each effect. (4) Audit notes list every effect and its kind.","notes":"Does not depend on Effect-as-enum refactor (crate epic step 4) but is much cleaner if that's landed. Depends on the AppState split so effects that cross slices are clearly cross-cutting.\nAUDIT (AC 4) — all 58 effects and their kinds, as of the closing commit:\n\nCellData (12): AddCategory, AddFormula, AddItem, AddItemInGroup, ApplyAndClearDrill, CleanEmptyRecords, ClearCell, RemoveCategory, RemoveFormula, RemoveItem, SetCell, SortData\n\nLayoutGeometry (14): CreateView, CycleAxis, DeleteView, HideItem, SetAxis, SetNumberFormat, SetPageSelection, ShowItem, SwitchView, ToggleGroup, TogglePruneEmpty, TransposeAxes, ViewBack, ViewForward\n\nWholeWorkbook (3): AcceptWizardModel, ImportJsonHeadless, LoadModel\n\nViewOnly (29): AbortChain, ChangeMode, CloseWizard, EnterEditAtCursor, ExportCsv, HelpPageNext, HelpPagePrev, HelpPageSet, MarkDirty, RebuildLayout, Save, SaveAs, SetBuffer, SetColOffset, SetDrillPendingEdit, SetPanelCursor, SetPanelOpen, SetRowOffset, SetSearchMode, SetSearchQuery, SetSelected, SetStatus, SetTileCatIdx, SetTransientKeymap, SetYanked, StartDrill, StartImportWizard, ToggleCatExpand, WizardApply\n\nNotes on the non-obvious ones:\n- SetSelected/SetRowOffset/SetColOffset are ViewOnly despite writing the shared, persisted View: cursor and scroll are per-client. Residual inconsistency filed as improvise-8uz.\n- Save/SaveAs/MarkDirty touch model_state (dirty, file_path) but change nothing renderable -\u003e ViewOnly.\n- ExportCsv reads the model and writes a file; no state change -\u003e ViewOnly.\n- RebuildLayout rebuilds a derived cache -\u003e ViewOnly.\n- SortData reorders the data store, which changes records-mode row identity -\u003e CellData.\n- ApplyAndClearDrill writes cells through free-function helpers -\u003e CellData.\n- SetTransientKeymap lives in command/keymap.rs, not effect.rs.","status":"closed","priority":2,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T07:22:02Z","created_by":"spot","updated_at":"2026-08-29T20:03:28Z","started_at":"2026-08-29T19:57:52Z","closed_at":"2026-08-29T20:03:28Z","close_reason":"All 58 effects classified. EffectKind::{ViewOnly, Model(ModelChange)} with ModelChange::{CellData, LayoutGeometry, WholeWorkbook}. Full audit table in the notes (AC 4).\n\nkind() HAS NO DEFAULT IMPL. The design section offered a per-struct method; making it required instead means a new effect cannot forget to classify itself. That matters because the failure mode is silent — a mis-declared ViewOnly edit would simply never reach connected clients. It paid off immediately: the compiler found a 58th Effect impl, SetTransientKeymap, living in command/keymap.rs rather than effect.rs, which I would have missed classifying by eye.\n\nDERIVED THE CLASSIFICATION, THEN VERIFIED IT. I extracted what each apply() body touches and reasoned about it in Prolog rather than eyeballing 58 effects. Two things that came out of it:\n\n1. The naive signal is WRONG for 21 effects. The View is stored inside model_state.workbook, so 'touches model_state' does not mean 'is a model change'. SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, SwitchView, ViewBack, ViewForward, CreateView, DeleteView and TogglePruneEmpty are LayoutGeometry, not CellData; Save/SaveAs/MarkDirty touch model_state for bookkeeping only and are ViewOnly. A classification done by grepping for model_state would have had the projection layer broadcasting cell updates for cursor moves.\n\n2. My own extraction was wrong twice, and I caught it by disbelieving the output rather than by luck: multi-line method chains hid workbook.model.set_cell(), and free-function helpers hid ApplyAndClearDrill's writes, so SetCell, SortData, RemoveItem and others first came out ViewOnly — obviously false. Fixed the extraction (flatten chains) and re-derived, then read the remaining suspicious ones directly.\n\nTESTS VERIFY DECLARATIONS EMPIRICALLY, not by restating them (AC 3). Rather than a table asserting kind() == what kind() returns, the tests apply each effect to a probe App and fingerprint the shared state it claims not to touch: view_only_effects_leave_shared_state_alone (16 effects, model data and view config both unchanged), cell_data_effects_actually_change_model_data, layout_geometry_effects_actually_change_view_config. Verified they bite by mislabelling SetCell as ViewOnly — the exact silent-drop bug — which fails cell_data_effects_actually_change_model_data.\n\nJUDGEMENT CALL, recorded rather than buried: SetSelected/SetRowOffset/SetColOffset write the shared, persisted View but are per-client concerns — one client scrolling must not move another's viewport — so they are ViewOnly. They are the only ViewOnly effects that mutate shared workbook state, which the test has to special-case. Filed improvise-8uz to resolve that properly before improvise-cqq builds the projection layer on this.","dependencies":[{"issue_id":"improvise-mae","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:36Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-vb4","title":"Split AppState into ModelState + ViewState (standalone refactor)","description":"Refactor the current App struct to make the model/view slice boundary explicit in types. Today App mixes Model with session state (mode, cursor, scroll, buffers, etc.); split them so that ModelState holds document state and ViewState holds per-session UI state. Server continues to own both, but as distinct fields. Valuable independently of the browser epic: cleaner persistence (only model slice serializes), cleaner undo (undo walks model effects only), better test boundaries, and it's the foundational slice separation every downstream issue depends on.","design":"pub struct ModelState { model: Model, file_path: Option\u003cPathBuf\u003e, dirty: bool }. pub struct ViewState { mode: AppMode, selected, row_offset, col_offset, search_query, search_mode, yanked, buffers, expanded_cats, help_page, drill_state_session_part, tile_cursor, panel_cursors }. App becomes { model_state: ModelState, view_state: ViewState, layout: GridLayout, ... }. Audit every App field and assign it. Gray zones: Model.views stays in Model (document state). drill_state staging map is session (View); commit flushes to Model. yanked is View. file_path/dirty are Model.","acceptance_criteria":"(1) App contains ModelState and ViewState as typed subfields. (2) No App field lives outside exactly one slice (or is explicitly called out as derived, like layout cache). (3) All existing tests pass. (4) Effect apply methods updated to take \u0026mut the correct slice where obvious, or \u0026mut App where cross-cutting. (5) Audit report in issue notes: every field classified.","notes":"Additional implementation note merged from superseded improvise-dwe: a small number of effects today read App-level/layout-derived state at apply time (e.g. EnterEditAtCursor reads display_value from layout). For these, pre-compute the derived value in the command's Cmd::execute (where CmdContext has layout access) and bake it into the effect struct's own fields. Apply bodies then become pure model/view state writes. Example: EnterEditAtCursor { target_mode, initial_value: String } with initial_value computed in execute and consumed by apply.\n\n---\n\n## Field-by-field audit (improvise-97o)\n\nRefactor landed across 4 commits: f11d79f (3vr scaffold), 917b928 (x2c\nModelState fields), 9ad8abd (ew0 ViewState fields), 7249fac (99k residue\ndocs). All 29 original App fields classified below.\n\n| Field | Slice | Why |\n|---|---|---|\n| workbook | ModelState | Document semantics (Workbook = Model + named views + active view). |\n| file_path | ModelState | Path on disk for save/load — IO bookkeeping. |\n| dirty | ModelState | Divergence from on-disk state — IO bookkeeping. |\n| mode | ViewState | Current AppMode (Normal / Editing / etc.). |\n| status_msg | ViewState | Transient status-line text. |\n| wizard | ViewState | Optional import wizard, present only in import flow. |\n| search_query | ViewState | Current search string. |\n| search_mode | ViewState | True while typing into the search prompt. |\n| formula_panel_open | ViewState | Formula side panel toggle. |\n| category_panel_open | ViewState | Category side panel toggle. |\n| view_panel_open | ViewState | View side panel toggle. |\n| cat_panel_cursor | ViewState | Selected row in category panel. |\n| view_panel_cursor | ViewState | Selected row in view panel. |\n| formula_cursor | ViewState | Selected formula in formula panel. |\n| yanked | ViewState | Last `y`-yanked CellValue for paste. |\n| tile_cat_idx | ViewState | Highlighted tile in tile-select mode. |\n| view_back_stack | ViewState | View navigation history (back). |\n| view_forward_stack | ViewState | View navigation history (forward). |\n| drill_state | ViewState | Frozen records snapshot + staging edits for drill view. |\n| help_page | ViewState | Current page in help overlay. |\n| expanded_cats | ViewState | Categories expanded in the panel tree. |\n| buffers | ViewState | Named text-entry buffers (edit, formula, command, ...). |\n| transient_keymap | ViewState | Emacs-style prefix key chord state. |\n| layout | App-derived | Pure function of model_state.workbook + view_state.drill_state + active view; rebuilt via rebuild_layout(). |\n| last_autosave | App-runtime | Wall-clock instant for autosave debounce. |\n| term_width | App-runtime | Terminal width, updated on resize. |\n| term_height | App-runtime | Terminal height, updated on resize. |\n| abort_effects | App-transient | Flag scoped to a single apply_effects call; reset on entry. |\n| keymap_set | App-config | Keymap configuration loaded once at startup. |\n\nCounts: ModelState 3, ViewState 20, App-derived 1, App-runtime 3, App-transient 1, App-config 1 = 29 total.\n\nimprovise-drg (step 5: narrow Effect::apply signatures) was deferred —\nthe issue's framing has a hidden trait-design decision (you can't change\none impl's signature without changing the trait). Three options floated\n(split into ModelEffect/ViewEffect/AppEffect, body-level rebind, or\ndefault-method dispatch); none picked yet. vb4 does not depend on drg.","status":"closed","priority":2,"issue_type":"feature","assignee":"jeeves","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-05-03T04:16:32Z","close_reason":"Audit report written into vb4 notes per criterion (5). All 29 original App fields classified into ModelState (3) / ViewState (20) / App-derived (1) / App-runtime (3) / App-transient (1) / App-config (1). vb4 acceptance criteria all met: (1) typed model_state + view_state subfields ✓ (2) no overlap ✓ (3) tests pass ✓ (4) Effect::apply still \u0026mut App per criterion (4)'s allowance for cross-cutting; impl-level narrowing deferred to improvise-drg (step 5) which is P3/optional. Refactor commits: f11d79f, 917b928, 9ad8abd, 7249fac.","dependencies":[{"issue_id":"improvise-vb4","depends_on_id":"improvise-97o","type":"blocks","created_at":"2026-04-26T11:17:18Z","created_by":"jeeves","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","acceptance_criteria":"Closes when improvise-1ey does. 1ey's criteria (1)-(4) and (6) are met and evidenced better than the ticket asked — browser_round_trip.rs drives real frames through a real Host, and harness/tests/shell.spec.js renders web/app.js under Chromium. (5), 'scrolling fetches cells for the new viewport', is covered in principle by set_viewport and GridExtent but NOT by an executed test, because web/demo.improv is too small to scroll — improvise-plcx is what would close that. Decide whether (5) needs the test before this epic closes.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-1ey, which is held by\nimprovise-plcx.\n\nThis epic's criteria asked to \"decide whether (5) needs the test before this\nepic closes\". The decision is YES, and the reason is that (5) is not the same\nquestion as the resize test that stands in for it. improvise-1ey's annotation\nnow says so: browser_round_trip.rs covers the EXTENT changing at a fixed\noffset, while scrolling is the OFFSET moving, which is exactly where gotcha\n47's \"the window is the client's own fact\" and the GridExtent arithmetic bite\n— the row frame's own length is the window clipped to the grid and so differs\nprecisely at the bottom edge. Standing on the resize test would be counting a\ncriterion as met by a test that cannot see it, which is the defect shape this\nrepo names most often.\n\nimprovise-plcx supplies the fixture that makes the test possible.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-09-20T18:05:07Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-1ey","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":8,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6jk","title":"Epic: Browser frontend via synchronized Redux-style stores","description":"Build a browser frontend for improvise by treating the client as a thin Redux-style peer of the existing server. Commands are the wire format; each side runs a reducer over its own state slice. Server is structurally unchanged (still runs today's full App with ratatui session, effect pipeline, formula eval). Client is a new thin peer: ViewState + render cache + reduce_view + keymap + DOM renderer. Commands flow both directions — upstream (user intent resolved client-side) and downstream (projection commands emitted by the server after model-touching effects). See child issues for sequenced steps.","design":"Client holds ViewState (mode, cursor, scroll, minibuffer, search, yanked, expanded cats, drill buffer, panel cursors) plus a render cache (visible cells, labels, col widths). Server holds full App as today. Wire type: Command enum, serde over websocket. Upstream: user-initiated commands resolved by client-side keymap. Downstream: projection commands (CellsUpdated, ColumnLabelsChanged, etc.) emitted by server after effect application. Client reduce_view pattern-matches commands and applies view-slice effects; client never runs formula eval or touches Model. Server emits projections by walking the effect list after each command and computing per-viewport deltas per connected client. Effect split (model vs view) is a server-internal tag driving projection emission — never crosses the wire. Key→command resolution is client-side (keymap in wasm bundle) for local responsiveness.","acceptance_criteria":"Closes when improvise-1ey does. 1ey's criteria (1)-(4) and (6) are met and evidenced better than the ticket asked — browser_round_trip.rs drives real frames through a real Host, and harness/tests/shell.spec.js renders web/app.js under Chromium. (5), 'scrolling fetches cells for the new viewport', is covered in principle by set_viewport and GridExtent but NOT by an executed test, because web/demo.improv is too small to scroll — improvise-plcx is what would close that. Decide whether (5) needs the test before this epic closes.","notes":"Prereqs from crate-split epic (improvise-xgl): step 2 (improvise-core, improvise-36h) for shared types; step 5 (improvise-command split, improvise-3mm) so wasm client can import keymap + Command + reduce_view without ratatui. Step 4 (Effect enum, improvise-45v) useful but not strictly required. Target bundle: 200-500 KB compressed — formula layer stays server-side.\nCHECKED, wave 2 WU-P: STAYS OPEN, held by improvise-1ey, which is held by\nimprovise-plcx.\n\nThis epic's criteria asked to \"decide whether (5) needs the test before this\nepic closes\". The decision is YES, and the reason is that (5) is not the same\nquestion as the resize test that stands in for it. improvise-1ey's annotation\nnow says so: browser_round_trip.rs covers the EXTENT changing at a fixed\noffset, while scrolling is the OFFSET moving, which is exactly where gotcha\n47's \"the window is the client's own fact\" and the GridExtent arithmetic bite\n— the row frame's own length is the window clipped to the grid and so differs\nprecisely at the bottom edge. Standing on the resize test would be counting a\ncriterion as met by a test that cannot see it, which is the defect shape this\nrepo names most often.\n\nimprovise-plcx supplies the fixture that makes the test possible.","status":"open","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:21:21Z","created_by":"spot","updated_at":"2026-09-20T18:05:07Z","labels":["area:epic"],"dependencies":[{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqi","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cqq","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-cr3","type":"blocks","created_at":"2026-04-14T00:26:00Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-gsw","type":"blocks","created_at":"2026-04-14T00:25:59Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-mae","type":"blocks","created_at":"2026-04-14T00:25:57Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:25:58Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-6jk","depends_on_id":"improvise-vb4","type":"blocks","created_at":"2026-04-14T00:25:56Z","created_by":"spot","metadata":"{}"}],"dependency_count":7,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3mm","title":"Step 5: Extract improvise-command crate below improvise-tui","description":"Move src/command/ into an improvise-command crate that depends on improvise-core (+ formula) but NOT on ui/. The tui crate (with App, draw.rs, widgets) depends on improvise-command. This is the boundary most worth enforcing — it's where the current Cmd/Effect/App architecture is conceptually clean but mechanically unenforced. Should be mostly straightforward after step 4 (Effect-as-enum) because commands no longer transitively depend on App.","design":"Target shape: improvise-command contains command/ (Cmd, CmdContext, CmdRegistry, keymap.rs, all Cmd impls), ui/effect.rs (Effect trait + all impl blocks), AppState (the semantic state struct — workbook, mode, status_msg, dirty, buffers, expanded_cats, yanked, drill_state, search_query, search_mode, transient_keymap, view_back/forward_stack, panel cursors + open flags, help_page, wizard, file_path), AppMode, ModeKey, Keymap, MinibufferConfig, DrillState. No ratatui or crossterm deps. improvise-tui contains App { state: AppState, term_width, term_height, layout: GridLayout, last_autosave, keymap_set }, draw.rs, ui/ widgets, main.rs (binary). Depends on improvise-command + improvise-io + improvise-core + improvise-formula. Effect::apply takes \u0026mut AppState (per improvise-dwe), so all impl Effect for Foo blocks live in improvise-command without needing App. The apply_effects dispatch loop moves to tui: for e in effects { e.apply(\u0026mut self.state); } self.rebuild_layout();. cmd_context() bridges both layers — it reads layout/term_dims from App and everything else from AppState.","acceptance_criteria":"(1) crates/improvise-command/ contains command/ + ui/effect.rs + AppState + AppMode + ModeKey + Keymap + MinibufferConfig + DrillState. (2) No use crate::ui::draw, use ratatui, use crossterm imports remain in improvise-command. (3) improvise-tui contains App (wrapping AppState) + ui/ widgets + draw.rs + main.rs. (4) cargo build -p improvise-command succeeds without compiling ratatui or crossterm. (5) cargo test -p improvise-command runs command/effect tests in isolation. (6) All workspace tests pass. (7) cargo clippy --workspace --tests clean.","notes":"With improvise-dwe in place, this step is largely mechanical file moves + lib.rs re-exports, mirroring Phase B (improvise-36h) and Phase 3 (improvise-8zh). Key items: (1) AppMode and MinibufferConfig must be pure data (no rendering-layer types); audit before moving. (2) ImportWizard lives in AppState as session state — moves to improvise-command. Its render path is in improvise-tui's ui/import_wizard_ui.rs which stays up in tui. (3) KeymapSet + default_keymaps() both fine to move — they're pure data. (4) main.rs stays in improvise-tui as the binary entry point. (5) crates/improvise-tui/ replaces the current main improvise crate as the binary target; the root workspace's [[bin]] should probably point there. (6) The draw.rs event loop + TuiContext stay in improvise-tui. (7) Watch for accidental pub(crate) on items that become cross-crate — fix visibilities case by case.","status":"closed","priority":2,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-14T06:53:59Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-06-13T20:57:07Z","close_reason":"Done in d60ee8d (prep: command layer de-coupled from ratatui/crossterm via own command::key types + semantic MinibufColor) and a85e6ca (extraction). crates/improvise-command now holds command/ (cmd, keymap, parse, key), ui/{app,effect,cat_tree}.rs, and the pure viewport helpers; depends on core+formula+io and provably NOT ratatui/crossterm (cargo tree -e normal = 0 hits; grep = 0 hits). Tests: 665 workspace green (improvise-command 227 in isolation), clippy --workspace --all-targets clean. DEVIATION from the recorded design: App moved WHOLE into improvise-command rather than splitting an AppState struct out, and the root 'improvise' crate stays the binary (re-exporting improvise_command under crate::command / crate::ui::{app,effect,cat_tree} so widgets/draw.rs/main.rs are untouched) instead of a renamed improvise-tui crate. This matches the improvise-ltq design correction and keeps the diff a pure file-move + re-export.","dependencies":[{"issue_id":"improvise-3mm","depends_on_id":"improvise-dwe","type":"blocks","created_at":"2026-04-15T23:29:27Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} {"_type":"issue","id":"improvise-45v","title":"Step 4: Convert Effect trait to an enum (in-place, no crate change)","description":"Refactor Effect from a trait (Box\u003cdyn Effect\u003e with apply(\u0026self, \u0026mut App)) into a data enum, with a single apply(app: \u0026mut App, effect: \u0026Effect) function in the tui layer. This is the key semantic change that unblocks splitting command from ui: once effects are pure data, the command layer no longer transitively depends on App. Do this step in-place before any crate split so the diff stays isolated from packaging churn.","design":"Replace trait Effect with pub enum Effect { SetCursor { row, col }, SetMode(AppMode), AppendChar(char), ... } — one variant per current Effect struct (~50 variants). The existing Effect structs' fields become variant payloads. Move all apply() bodies into one apply_effect(app: \u0026mut App, e: \u0026Effect) function that matches exhaustively. Commands still return Vec\u003cEffect\u003e (no Box). Rationale: (1) design-principles.md §1 already claims 'effects can be logged, replayed, or composed' — an enum actually delivers that, a trait object does not. (2) matches the enum-heavy style used elsewhere (Binding, Axis, CategoryKind, BinOp). (3) exhaustive matching catches missed cases at compile time when adding new effects. Note: changes_mode() becomes a const match on the enum variant.","acceptance_criteria":"(1) trait Effect is gone. (2) Effect is a pub enum with one variant per previous struct. (3) apply_effect(app, effect) lives in ui/effect.rs (or similar) and is exhaustive. (4) Vec\u003cBox\u003cdyn Effect\u003e\u003e is replaced with Vec\u003cEffect\u003e in Cmd::execute signature and throughout command/. (5) All 568 existing tests pass. (6) No behavior change.","notes":"Biggest single semantic change in the epic. Do it in a branch, keep the commit history clean (one commit per submodule of effects migrated is fine). Watch for effects with nontrivial apply bodies — e.g., drill reconciliation and import wizard effects — those may need helper functions rather than inline match arms to keep apply_effect readable.","status":"closed","priority":2,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T06:36:11Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T06:26:50Z","close_reason":"Superseded. Effect stays as a trait — the original blocker (command transitively depending on App via Effect::apply(\u0026mut App)) is resolved by splitting App into AppState (semantic state, in improvise-command) + App (rendering state wrapper, in improvise-tui). Effect::apply takes \u0026mut AppState; the few effects that currently read App-level state (layout, display_value) pre-compute those values in their Cmd::execute and pass them via the effect struct's own fields (self). No enum conversion, no dual trait, no noise. See improvise-3mm for the revised design.","dependencies":[{"issue_id":"improvise-45v","depends_on_id":"improvise-8zh","type":"blocks","created_at":"2026-04-13T23:54:40Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-8zh","title":"Step 3: Extract improvise-io crate (persistence + import)","description":"Move src/persistence/ and src/import/ into an improvise-io crate that depends on improvise-core and improvise-formula. Mostly mechanical once step 2 lands; the forcing function is deciding what's actually part of improvise-core's public API vs. an accidental pub.","acceptance_criteria":"(1) crates/improvise-io/ contains persistence/ and import/. (2) The crate depends only on improvise-core and improvise-formula (plus external deps: pest, csv, flate2, chrono, etc.). (3) No use crate::ui::* or crate::command::* imports remain in moved code. (4) All persistence round-trip tests and import tests pass. (5) Consider whether to split into two crates (-persistence, -import) or keep as one — recommend one for now unless dep surface diverges.","notes":"Watch for leaks: persistence currently imports view::{Axis, GridLayout} and model::category::Group. Those exports must be marked pub in improvise-core. Import wizard uses CellKey, CellValue, Model, parse_formula — all already on the allowed path.","status":"closed","priority":2,"issue_type":"task","assignee":"spot","owner":"el-github@elangley.org","created_at":"2026-04-14T06:35:23Z","created_by":"spot","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-16T06:09:19Z","close_reason":"Done on worktree-improvise-ewi-formula-crate. Created crates/improvise-io/ containing persistence/ + import/; depends only on improvise-core and improvise-formula (plus external: anyhow, chrono, csv, flate2, indexmap, pest, serde, serde_json). No crate::ui::* or crate::command::* imports in moved code. All 616 tests pass (219 main + 190 core + 65 formula + 142 io); clippy clean; 'cargo build -p improvise-io' succeeds standalone. Kept as one crate per acceptance criterion #5.","dependencies":[{"issue_id":"improvise-8zh","depends_on_id":"improvise-36h","type":"blocks","created_at":"2026-04-13T23:54:39Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} @@ -431,129 +509,187 @@ {"_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-txh4","title":"Type-ahead across a refused commit: text typed into the reopened editor replays into the refused cell's editor, and the retry writes it there","description":"Found reviewing PR #274 at head 8d9de94e. Not a regression: at gh/main the retry writes the cursor cell, which is the same cell here.\n\n**Steps (real Host, the `host()` fixture):**\n1. A presses `l i 4 2`, so the editor is open on North/Cost with `42` typed.\n2. B writes North/Revenue. B's relays are held.\n3. A presses Enter. The commit is sent, the advance reopens the editor on South/Cost (1,1), and the buffer is empty.\n4. **Before the answer arrives,** A types `7` into the South/Cost editor. That sends the unawaited frame `[SetBuffer edit \"7\", SetOpenedEditor touched]`, which queues behind the awaited commit.\n5. The commit is refused with `RecordMoved(north)`.\n6. `refused()` drops the commit frame and replays the settled slice plus the rest of `in_flight`. The editor is back on North/Cost (0,1), but the buffer is **\"7\"**: the SetBuffer composed for the South/Cost editor lands in the restored North/Cost editor.\n7. `opened` is `Retry(north, Cost)`. The recapture matches, so it becomes `Target`.\n8. The retry Enter is `Accepted`.\n\n**Result:** North/Cost = 7. The user's 42 is gone, and 7 (typed for South/Cost) is stored in North/Cost.\n\nThe `Retry`/`Gone` rule (9ww8, 8phw) checks that the **cell** is the refused frame's cell. It does not check that the **text** belongs to that editor. The replay of type-ahead is the #221-era `refused()` design (settled + remaining in_flight).\n\n**Options, none tried:**\n- (a) On a refused commit, drop the in-flight editor frames composed after it, since they belong to an editor the refusal unmade.\n- (b) Restore the refused frame's own typed text as the buffer.\n- (c) Decline the retry when the buffer was touched by a later editor.\n\nNeeds a keypress inside one host round trip plus a crossing write, so it is P3. Related to improvise-n4i9 (type-ahead into an awaiting editor) and improvise-8phw.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:39:48Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T01:39:48Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-txh4","depends_on_id":"improvise-8phw","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-txh4","depends_on_id":"improvise-n4i9","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cz3o","title":"EnterEditAtCursor's status-not-refuse rule is unguarded, and a scripted commit whose advance lands on a declined cell prints a refusal to stderr (#277)","description":"Found in adversarial review of PR #277 (improvise-enz6), head aab62a36.\n\n1. **The status-not-refuse rule is unguarded.** `context/code/command.md` (#277) says `EnterEditAtCursor` writes a status line and not `App::refuse`, because the commit before it succeeded: \"A `commit-cell-edit` script line whose advance lands on a fold must not report its write as refused.\" Rival: `Some(refusal) =\u003e app.refuse(refusal)` in `EnterEditAtCursor::apply`. The whole workspace stays green (`cargo test --workspace --no-fail-fast`). Measured through the CLI, the rival breaks what the rule protects. With `_Measure` on rows [Revenue, Profit=formula] and Month on columns, `improvise cmd 'commit-cell-edit editing 7 Month/Jan _Measure/Revenue' -f t.improv` stores 7 and exits **1** under the rival. It exits 0 under the PR.\n\n2. **A successful scripted commit now prints a refusal-shaped line.** On the same fixture under the PR, the command exits 0 and stores 7, but stderr reads \"Profit is computed by a formula — edit the formula, not the cell\". At 202e7eac (gh/main) the same command prints nothing. The user asked to write Revenue, and the message is about a cell they did not touch. It comes from the re-entry the headless run does not need.\n\n3. **A false comment.** `resolve::AGGREGATE_DECLINE` is documented as \"What the host says of an aggregate, the fold of a collapsed group included\". The host says `FOLD_REFUSAL` (\"Collapsed group — press z to expand it first\") of a fold, and `context/code/clients.md` (#277) says so itself.\n\nAC: a test fails under the rival in (1). It can assert `!last_batch_refused()` after a commit whose advance lands on a declined cell, or the CLI exit code. Decide whether (2) is acceptable and say so in `context/code/cli.md`, or suppress the re-entry's status for a headless run. Correct the comment in (3).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:27Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:27Z","labels":["adversarial-review","area:cli","pr-277"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ik6l","title":"Browser Enter on a touched editor over a declined cell is a silent Upstream: no write, no message, editor stays open (#282's kept editor)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. It answers the orchestrator's question \"a touched editor survives; what happens at commit?\"\n\n#282 keeps a TOUCHED editor open on a cell the host later projects as declined (a fold, an aggregate or a formula's result). The case is an advance past the window followed by typing before the reply arrives. Enter on that editor goes through `resolve::commit`. Its `id` is Aggregate or Derived, so `written` -\u003e `record_at_cursor` -\u003e `None`, and `commit` returns `None`. The key outcome is `KeyOutcome::Upstream`. `Host::handle` answers a Key frame with nothing (gotcha 47), so Enter does nothing: no write, no message, and the editor stays open with the user's text.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed): `fold_host`, window (2, 6), cursor at (1,1). `i`, `Enter`, `7` are folded and sent before any reply is delivered, and then the replies are delivered. Then `Enter`: `resolve` returns Upstream. The mode is still Editing, the status is \"\", the buffer is \"7\", and the host's cell count is unchanged.\n\nThe TUI never reaches this state, because it declines the open. Its nearest equivalent is an editor already open on a fold (`an_editor_already_open_on_a_fold_commits_nothing_there`), where Enter says \"Collapsed group — press z to expand it first\". The browser should decline out loud the same way, with `declined_at`'s text. This is the same family as improvise-l3hm and improvise-kkcy (silent Upstream where the TUI declines).\n\nThe #282 docs say \"no write can follow on that cell\". That is true, but it does not say that Enter goes dead.\n\nAC: Enter on a touched editor over a declined cell composes a SetStatus decline, not Upstream. A test drives this state, and a rival that returns `None` fails it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:13Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:13Z","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kimw","title":"An empty-record write naming a non-host view re-derives that view's layout (and, with formulas, clones + recomputes the model) ~5x per command","description":"Found by the adversarial review of PR #281 (improvise-wpxr) at head ba3d2875.\n\nA wire SetCell/ClearCell of an EMPTY record, naming a view the host is not on, derives that view's layout once per resolution call, not once per command. The calls are: CheckedBatch's record tier (will_be_created_by_the_frame, stale_record), broken_chain, and model_effects (unwritable -\u003e resolve_cell and aggregate_refusal, then resolve_cell again). That is five or six derivations per write, before grid_in derives the same layout once more to project it.\n\nWithout formulas each derivation is only GridLayout::with_frozen_records, which is cheap (about 2.4ms measured). But App::with_layout_of goes through project::rederived_for. When the two views' none_cats differ and the document has any formula, that CLONES THE MODEL AND RUNS recompute_formulas on every call.\n\nMEASURED (release build, Apple Silicon laptop under load, so noisy; a scratch test, discarded). Document: Region(150) x Product(120) x _Measure{Revenue, Cost}, about 18k stored Revenue cells, plus one formula (Profit = Revenue - Cost). The host sits on \"Other\", which puts Product on None. The client names \"Default\".\n- ClearCell of an empty record: best of 5 = 1.82s.\n- SetCell of a stored record, same host: 0.65s.\n- One resolve_record of the empty record alone: 227ms.\n- So about 1.1s per write is repeated derivation.\n- For contrast, with no formula the empty-record write costs the same as the host-on-Default case (about 0.24-0.34s, dominated by the pre-existing projection cost, which is improvise-swre).\n\nWho pays: the browser worker deployment most of all. There, the App's view_state is the host's own, so every client on a non-default view is \"not on the host's view\". Holding clear-cell or paste over a column of empty cells pays about 1s+ per key on a mid-size document with a formula.\n\nFix shape: derive once per command, or once per frame. For example, resolve the named view's layout once in model_effects and in the CheckedBatch record tier and pass it down. Or memoise the derived (model, layout) keyed on (view name, layout_version, document_generation), together with improvise-swre. Correctness is not in question: #281's resolution is right. This is cost only.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:23:47Z","created_by":"agent-review-281","updated_at":"2026-09-24T01:23:47Z","labels":["adversarial-review","performance","pr-281"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s2gb","title":"every_silent_binding_is_on_the_list walks only Normal and search: a silent binding in a panel mode passes the whole suite","description":"Found reviewing PR #280 (improvise-oyaa) at 88e53aed. The new guard walks only ModeKey::Normal and SearchMode, but the browser also stands in the formula, category and view panels, TileSelect, Help, Editing and RecordsNormal. Rival: I added .bind(KeyCode::Char('Z'), \"commit-export\") to the FormulaPanel keymap. The client has no arm for commit-export, so Z goes Upstream, a black hole. cargo test --workspace stayed at 0 failed. The #264 help-row guard cannot see it because no help row names it, and the #280 guard cannot see it because it never walks FormulaPanel. A throwaway probe ran the PR's own every_binding and press over every AppMode's keymap, with the search query both empty and seeded. At head it finds no silent, unlisted binding in any mode the browser can enter. The only hits are modes whose door the browser refuses (COMMAND, EXPORT, SAVE AS, IMPORT) and RECORDS INSERT, whose entry edit-or-drill declines. So widening the guard to every mode except those costs nothing today and closes the gap. The exclusions should be stated as the modes declined() refuses at the door, not as a hand list.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:21:55Z","created_by":"agent-review-264","updated_at":"2026-09-24T01:21:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i5z6","title":"improvise-formula lib test binary measured over the 2s tripwire under load (2.2-5.5s)","description":"Reported by two agents on 2026-09-23 (M4 Max, heavy parallel load): improvise-formula's lib test binary took 2.21s, 3.84s and 5.47s; earlier 1.77s. May be load, but design-principles §7 says compare to the run's median binary. Measure unloaded and as a ratio to the median; if it is an outlier, find the cost (likely proptest/grammar work, cf. improvise-8shu's OnceLock fix for improvise-io).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:38Z","created_by":"orchestrator","updated_at":"2026-09-24T01:18:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3j70","title":"The GIT_CONFIG_GLOBAL/NOSYSTEM nulls in run_git have no witness: vwjj's -c core.excludesFile=/dev/null masks the excludes-based one","description":"Found while doing improvise-eahe, and true at #276's head before eahe as well.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1` (improvise-53zz). The witness for both halves is a global or system config whose `core.excludesFile` refuses `*.gz`. Since improvise-vwjj, `run_git` also passes `-c core.excludesFile=/dev/null`, and a `-c` argument outranks every config file. So the witness can no longer see either half.\n\nMeasured by injecting each rival into `tests/shipped_documents_load.rs` and running the binary. Both rivals are **green**, both on the per-setting tests at ddcdf93a and on eahe's combined test:\n- dropping `GIT_CONFIG_GLOBAL=/dev/null`;\n- dropping `GIT_CONFIG_NOSYSTEM=1`.\n\nThe two lines are still worth keeping, because they null every *other* config key a user might set. But no test pins them. The fix is a witness that uses a config key which no `-c` argument overrides and which breaks `git init` or `git add`. For example, `core.sharedRepository = nonsense` might fail `git init`, but that is untested; check it on git 2.55 first. Put it in the same hostile child as eahe's other settings, and show both rivals red.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:29Z","created_by":"agent-docs-wave","updated_at":"2026-09-24T01:24:58Z","started_at":"2026-09-24T01:24:58Z","lease_expires_at":"2026-09-24T01:29:58Z","heartbeat_at":"2026-09-24T01:24:58Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5m9z","title":"Browser advance past the announced window opens the editor on an unprojected cell","description":"Found on PR #277 (improvise-enz6). resolve::reopened_at asks the landing cell's projected CellId to decline folds/formulas; an advance past the client's announced viewport lands on a cell with no projected id yet, so the browser opens the editor there regardless. No write can follow (the commit needs a projected record), but the editor state diverges from the TUI, which declines. Fix: treat an unprojected landing as await-the-host (like opened_on's awaiting seed) rather than open.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:03:25Z","created_by":"orchestrator","updated_at":"2026-09-24T01:03:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-oyaa","title":"NEEDS_THE_HOST completeness is checked only where a help row names the key","description":"Found on PR #275 (improvise-fo0w). resolve::NEEDS_THE_HOST's two guards (#264) prove no false entries and that no help row is an unmarked dead key, but not that every dead binding is listed: removing force-quit from the list fails no test, because no help row names Ctrl-Q. Fix: derive completeness from the Normal and search-mode keymaps (every binding either resolves locally, declines out loud, or is listed), not only from help rows.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:24Z","created_by":"orchestrator","updated_at":"2026-09-24T01:02:41Z","started_at":"2026-09-24T01:02:41Z","lease_expires_at":"2026-09-24T01:07:41Z","heartbeat_at":"2026-09-24T01:02:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0629","title":"a_help_row_the_browser_cannot_run_is_drawn_marked does not see an ex row drawn plain","description":"Found reviewing PR #264 (improvise-uln5) at cf5c6fc7. The render test asserts the search row's kind is dim, but for the ex-command (Literal) rows it asserts only that the description contains 'not in the browser'. Rival: in RenderHelpRow's Literal arm, override kind back to 'dim' only when the macro's dim flag is set (ignoring runs_the_command_line). The non-dim ex rows (:w, :q, :add-cat ...) then render as help-keys with a marked description, and all 21 render tests pass. The production code is correct today (verified in Chromium: every ex row is help-dim); this is a missing assertion. Fix: assert row.kind == dim for every ex row too.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:40:35Z","created_by":"agent-review-264","updated_at":"2026-09-24T00:40:35Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-joed","title":"A commit declined as 'still waiting' loses the typed text: Enter's welded clear-buffer still runs","description":"Found by agent-8xq0 (PR stacked on #221). In the browser, a keyboard commit whose target is not captured yet (the host has not answered this client's own last write) returns the status 'still waiting for the host to answer your last edit' and writes nothing. Since improvise-8xq0 this applies to value cells as well as coordinate renames. But Enter in Editing/RecordsEditing is the Sequence [commit-cell-edit, clear-buffer edit], and the sequence resolver keeps the clear-buffer step, so the editor stays open with an EMPTY buffer: the text the user typed is lost. Pinned as observed in resolve::tests::a_value_commit_before_the_host_answers_the_last_one_waits_out_loud (which asserts only the status and no write). improvise-fy9o (PR #227) moves the clear to the head of the commit command; whether the waiting decline then keeps the text depends on where that clear lands relative to the decline, so check after both merge. Reachable only by pressing Enter twice inside one host round trip.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:36:48Z","created_by":"agent-8xq0","updated_at":"2026-09-24T00:51:06Z","started_at":"2026-09-24T00:51:06Z","lease_expires_at":"2026-09-24T00:56:06Z","heartbeat_at":"2026-09-24T00:51:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3til","title":"App::restore's rebuild-after-view_state order is unguarded: rebuilding before view_state is restored passes the suite","description":"Found by the adversarial review of PR #261 (improvise-8wy9) at head 86b3d72c.\n\nApp::restore rebuilds the layout LAST, after view_state is restored. rebuild_layout reads view_name(), which comes from view_state.active_view, so the order matters. Nothing guards it.\n\nRIVAL THAT PASSES: move `self.rebuild_layout();` up to just after `self.model_state.workbook = snapshot.workbook;`, so it runs before `self.view_state = snapshot.view_state`. cargo test -p improvise-command --no-fail-fast stays green (648 passed), and so does #261's new test. Its frame never moves the host's view, so both orders give the same layout.\n\nWHY IT MATTERS: a client's DeleteView of the view the host is on moves the host's view_state.active_view (effect::DeleteView, `app.view_state.active_view = app.view_name()`). If that frame is then refused and rolled back, the rival builds the layout for the default view, then puts the host back on the deleted-and-restored view. The host's layout then describes a different view from the one it is on, and the next frame resolves its surrogates against that wrong grid.\n\nWITNESS (a scratch test, run and then discarded): host() plus a view \"Other\" (Region on Column, _Measure on Row, so 1 row x 2 cols), with the host switched to Other and the layout rebuilt. A client sends the frame [DeleteView(Other), RemoveItem(South), SetCell(South)]. It is refused ChainBroken. The test asserts view_name()==\"Other\" and layout.row_count()==1 and col_count()==2. It passes at 86b3d72c. Under the rival it fails at row_count, 2 against 1.\n\nCHEAP FIX: add that test next to a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid in ui/host.rs. Better still, follow it with a write that resolves only through Other's layout.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-24T00:21:33Z","created_by":"agent-review-261","updated_at":"2026-09-24T00:22:36Z","started_at":"2026-09-24T00:22:36Z","lease_expires_at":"2026-09-24T00:27:36Z","heartbeat_at":"2026-09-24T00:22:36Z","labels":["adr-001","adversarial-review","pr-261","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qnry","title":"Tab-commit's rightmost-column clamp in the browser resolver is unasserted: dropping .min(col_max) passes the whole workspace","description":"Found in the adversarial review of PR #256 (improvise-rq3f) at head eb146bcc. This predates #256, which is test-only.\n\nIn crates/improvise-protocol/src/resolve.rs, `advanced` has the arm `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))`. Dropping the clamp gives `Some((row, col + 1))` (rival `right_unclamped`). That passes the whole workspace: cargo test --workspace --no-fail-fast, 1942 passed, 0 failed.\n\n#256's new test `tab_in_an_editor_commits_and_lands_one_column_right` sits on a centred cursor on purpose, so no edge clamps the move. That makes it blind to the clamp. The host's `CommitAndAdvance` (`AdvanceDir::Right`, commit.rs:1020) clamps with `(col + 1).min(col_max)`. Without the clamp the browser, pressing Tab at the rightmost column of a non-records grid, would send `SetSelected { col: cols }` past the grid and open the editor on no cell.\n\nFix: add a lockstep case, not a unit test. Put the cursor at the rightmost column of a non-records grid at a nonzero row, press i then Tab, and compare the selection and mode with the host. Relation: improvise-3tt4's acceptance test (lockstep Tab at the rightmost column, comparing buffers) may cover this incidentally. Check it against `right_unclamped` before closing either.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:19:59Z","created_by":"agent-review-256","updated_at":"2026-09-24T00:21:18Z","started_at":"2026-09-24T00:21:18Z","lease_expires_at":"2026-09-24T00:26:18Z","heartbeat_at":"2026-09-24T00:21:18Z","labels":["adversarial-review","pr-256"],"dependencies":[{"issue_id":"improvise-qnry","depends_on_id":"improvise-3tt4","type":"related","created_at":"2026-09-24T00:20:09Z","created_by":"agent-review-256","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-98pd","title":"Three first-party sources still write non-item coordinates: set-cell, undeclared Data item, stale page selection","description":"Follow-up to improvise-gk51 (option (b) from that bead). gk51 made the projection and the refusal ask one predicate, `Model::first_non_item`, so a cell whose coordinate names no item now projects as `CellId::NonItem` and is offered to no one. That repairs the disagreement. It does not stop such coordinates from entering the document. Three first-party sources still create them:\n\n- **The `set-cell` script command.** `effect_cmds.rs` `SetCellCmd` writes the key as given and adds no item. For example, `set-cell 5 Region/Mars _Measure/Revenue`.\n- **A `.improv` `## Data` line naming an undeclared item.** `persistence/parse.rs` sets the cell and adds no item.\n- **A page selection naming no item.** `View::set_page_selection` does not validate. Every pivot cell's key then carries the stale item, and since gk51 the whole grid projects as `NonItem`. Found while testing gk51; the ticket did not predict it.\n\nThe fourth source, drill commits after an item is removed, is improvise-5mqm.\n\nDecide per source whether to refuse, to add the item, or to accept and let the projection decline, and write the decision down.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:17:27Z","created_by":"agent-viewhist","updated_at":"2026-09-24T00:17:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a61f","title":"improvise-wasm-client lib tests compile only under --workspace: test-support feature not requested in its dev-deps","description":"Found in the adversarial review of PR #259 at c349d14d. The same code is on gh/main 202e7eac, so the gap predates both PRs.\n\n`cargo test -p improvise-wasm-client --lib` does not compile:\n\nerror[E0277]: the trait bound `FormulaEntry: From\u003c\u0026str\u003e` is not satisfied\n --\u003e crates/improvise-wasm-client/src/render.rs:793\n\n`impl From\u003c\u0026str\u003e for FormulaEntry` is `#[cfg(any(test, feature = \"test-support\"))]` in improvise-protocol/src/render.rs:757. The only crate that enables `test-support` is improvise-command, in its own [dev-dependencies]. improvise-wasm-client's dev-dependencies do not request it. `cargo test --workspace` compiles only because feature unification pulls the feature in from improvise-command's test build, which is the same shape repo-map's Wasm section warns about for the wasm job.\n\nCI runs only --workspace, so it cannot see this. It bites anyone who runs one crate's tests, and it would bite a per-crate CI split.\n\nFix candidate: add `improvise-protocol = { path = \"../improvise-protocol\", features = [\"test-support\"] }` to improvise-wasm-client's [dev-dependencies]. Check: `cargo test -p improvise-wasm-client --lib` compiles standalone. It is also worth a sweep: run `cargo test -p \u003ccrate\u003e --no-run` for every workspace member.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:26Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T00:20:20Z","started_at":"2026-09-24T00:20:20Z","lease_expires_at":"2026-09-24T00:25:20Z","heartbeat_at":"2026-09-24T00:20:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a0i3","title":"shipped_documents_load: GIT_LITERAL_PATHSPECS or GIT_GLOB_PATHSPECS in the caller's env fail 9 of 10 tests","description":"Found in the adversarial review of PR #260 at 322de370 (git 2.55.0). tests/shipped_documents_load.rs::git() removes only the names that git rev-parse --local-env-vars lists, and the pathspec-magic variables are not in that list. shipped_documents() runs ls-files -- '*.improv' '*.improv.gz'.\n\nMeasured:\n- GIT_LITERAL_PATHSPECS=1 cargo test --test shipped_documents_load: 1 passed, 9 failed. every_shipped_document_loads fails its \"at least one shipped document\" precondition (line 33), because the globs are read literally.\n- GIT_GLOB_PATHSPECS=1: the same 1 passed, 9 failed, because '*' does not cross '/' under glob magic.\n\nThe failures are loud, not vacuous passes. This is the same spurious-red class as improvise-53zz, improvise-vwjj and improvise-pr3i, reached through pathspec parsing rather than through excludes.\n\nNot measured: GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS. GIT_ATTR_SOURCE=HEAD also fails loudly, with 8 of 10 failing on \"bad --attr-source\", as the pr3i ticket already notes.\n\nFix candidate: env_remove GIT_LITERAL_PATHSPECS, GIT_GLOB_PATHSPECS, GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS (and GIT_ATTR_SOURCE) in git(). Test: rerun_with_env with each one set, for a scratch-repo shape test AND for every_shipped_document_loads.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:25Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T00:14:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-opbo","title":"GridLayout::evaluate_in/column_total accept a FoldMemo built by another layout and silently use its aggregation and hidden set","description":"Found reviewing PR #258 (improvise-pntu/0jw5, head 6b3577a0). ChatGPT Codex raised the same P2 on #258. After pntu, FoldMemo owns agg+covers, and GridLayout::evaluate_in(model, key, memo) / column_total(model, col, memo) accept ANY \u0026FoldMemo and ignore self's none_cats and hidden set. So layout_b.evaluate_in(.., \u0026layout_a.fold_memo()) evaluates under layout A's aggregation and hidden predicate.\n\nDemonstrated (scratch test, reverted): margin_book([\"R = Revenue / Cost\"]) with Q1 collapsed. View A shows all months; view B is A with Feb hidden. Key {Month=Q1,_Measure=R}. b.evaluate(key) = 2.0 (10/5); b.evaluate_in(key, \u0026a.fold_memo()) = 5.0 (40/8).\n\nIt is wrong on the memo's FIRST use, so here #258 is wider than #247, where a fresh foreign memo was harmless. Not reachable from production today: the only caller is viewport::grid_body, which builds layout.fold_memo() for the same layout. context/code/core.md section 14 names it as open (\"a memo built for one layout and passed to another's evaluate_in\"), but no ticket existed.\n\nFix shape: make the pairing unrepresentable. For example, layout.fold_memo() returns a per-build evaluator borrowing \u0026self that exposes evaluate(key)/column_total(col), and the memo-taking GridLayout methods go away. Test: the two-layout repro above, asserting b's reading through the shared evaluator equals b.evaluate.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:23Z","created_by":"agent-review-258","updated_at":"2026-09-24T00:30:38Z","started_at":"2026-09-24T00:30:38Z","lease_expires_at":"2026-09-24T00:35:38Z","heartbeat_at":"2026-09-24T00:30:38Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n4i9","title":"Type-ahead into an awaiting cell editor replaces the value the TUI extends: fast Tab Tab 5 stores 5 where the TUI stores 5095 (#227)","description":"Found while verifying improvise-mdfc on #227 (head 3fcd407b), in Chromium over the worker, with tall.improv and R01/Cost = 50 in the rightmost column.\n\nKeys `i 9 Tab Tab 5 Enter`, pressed back to back by Playwright, store 5095 or 5, depending on whether `5` arrives before the worker's answer. In 5 runs: 5095 twice, 5 three times. The TUI always stores 5095. With a 200 ms pause after the second Tab, the editor shows `509` and the browser stores 5095 in 5 of 5 runs. So the seed itself is right after mdfc; this is the type-ahead half.\n\nCause: the reopened editor is `OpenedEditor::awaiting()`, with an empty buffer. A key typed before the answer marks it touched, and `settle_awaited_seed` then never seeds it. The key REPLACES the value the TUI would have extended. #227 accepted this as a cost (\"what the user sees is what gets written\", protocol.md §3). It is never another record's number, but it does diverge from the TUI whenever the answer is slower than the next key: a held key, or any `?ws=` socket.\n\nOptions, none tried:\n(a) Record the characters typed while awaiting, and on settle write seed + typed. Backspace while awaiting needs a rule.\n(b) Paint the cached value dimmed as a placeholder while awaiting, as #227's review suggested, so the user at least sees that the value will be replaced.\n(c) Hold keys typed while an awaiting editor is unsettled until the answer arrives.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:09Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T00:06:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-29w2","title":"pr-watch-test: the CLOSED case no longer exercises the wait; closed PR #46 reports green MERGEABLE/BLOCKED and a rival passes 52/52","description":"Found by the delta review of PR #253 at 8cdea3a4 (b13355c7..8cdea3a4), which answers improvise-24lz.\n\nAt 8cdea3a4 the CLOSED fixture was changed from UNKNOWN/UNKNOWN to CONFLICTING/DIRTY, the shape closed PR #74 returns live. That shape is real, but it has a side effect. `mergeable == CONFLICTING` breaks the loop on the first poll whether the state check exists or not. So the CLOSED \"read once, not waited on\" case now holds by the loop's structure: no rival can fail it without also failing the CLOSED exit-code case.\n\nA different live closed shape does need the fix. Closed PR #46 has every check green and reports MERGEABLE/BLOCKED. A green BLOCKED rollup is waited on, and no test has that shape.\n\nRival (R8) that passes pr-watch-test.sh 52/52: exit 5 at the top of the loop for MERGED only, and exit 5 for CLOSED only after the loop breaks. Live against #46 with --timeout 15:\n- base 202e7eac: exit 3, \"timed out ... MERGEABLE/BLOCKED\"\n- R8: exit 3\n- 8cdea3a4: exit 5\n\nSo the CLOSED half of improvise-1qaa can regress with the suite green. Before 8cdea3a4, the rival \"state checked only at timeout\" also failed the CLOSED poll-count case. It no longer does: that case now passes, and only the exit-code case catches the rival.\n\nThe doc claim is also wrong. tooling.md at 8cdea3a4 says: \"A closed one need not: #74 reads CONFLICTING/DIRTY, which already ended the wait\". #46 shows a closed PR can hit the green-BLOCKED wait.\n\n## Steps to Reproduce\nApply R8 to scripts/pr-watch. Run scripts/pr-watch-test.sh and get 52 passed. Then run `scripts/pr-watch 46 --timeout 15`: it exits 3.\n\n## Acceptance Criteria\n- Add a CLOSED fixture in the #46 shape: MERGEABLE/BLOCKED with a green rollup, asserting exit 5 and one poll. Keep the #74 shape as well.\n- Confirm that R8 and \"state checked only at timeout\" each fail the new case.\n- Correct the tooling.md sentence.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:06Z","created_by":"agent-review-253","updated_at":"2026-09-24T00:06:06Z","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sx7h","title":"#257's su4u restore survives commit-and-advance: Editing/RecordsEditing re-enter an equal mode, so a stale message follows the editor onto the next cell","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7.\n\n#257's `draw.rs::press` puts a stale status message back when the mode before a key has a minibuffer and the mode after it is EQUAL. The PR body and `context/code/tui.md` say that leaving the minibuffer, \"by a successful commit or by Esc\", drops the message. For two of the eight text-entry modes that is false. `Editing` and `RecordsEditing` commit with `commit-cell-edit` and `commit-and-advance-right` (`CommitAndAdvance`). That command drops to Normal, commits, advances, and then `EnterEditAtCursor` re-enters the same `AppMode::editing()`/`records_editing()` value. So the mode after an Enter or Tab that committed and moved to ANOTHER cell equals the mode before it. The commit writes no message, so the stale one is restored beside a different cell's editor. It keeps being restored across every commit until Esc or some key writes a message.\n\nReproduced with a scratch test driving `press`, not committed. Workbook: Region{North}, measures Cost/Revenue, one cell. Keys: `R`, `o`, `5`, Tab, `7`. After `o` the status is \"Added new record row\", in RecordsEditing at (1,0). Tab commits \"5\" and moves to (1,1). \"Added new record row\" is still `status_msg` and is still drawn beside `edit: 7`. With a second `o` on the existing row, the refusal \"That record row is already here — ... fill this one in first\" survives the Tab that filled the coordinate in. At base 202e7eac (#233) both vanish on the first key.\n\nWhy the test misses it: `a_minibuffer_refusal_stands_while_the_user_fixes_it` uses only FormulaEdit. That is the one-category fixture shape. FormulaEdit's successful commit leaves the mode AND writes \"Formula added\", so the test cannot tell \"same mode\" from \"same editor session\".\n\nMode equality stands in for \"the same minibuffer session\", and a commit that re-enters the same mode breaks it. Candidate discriminators, for the author to judge: the cursor/`opened_editor` also unchanged, or a restore only when the key did not reach a commit. The second is the keymap copy the PR rightly avoided. The test for the fix must press Tab in (Records)Editing with a stale message and assert that it is gone.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:18Z","created_by":"agent-review-257","updated_at":"2026-09-24T00:10:48Z","started_at":"2026-09-24T00:10:48Z","lease_expires_at":"2026-09-24T00:15:48Z","heartbeat_at":"2026-09-24T00:10:48Z","labels":["adversarial-review","area:tui","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gk51","title":"Wire writes to a records-mode row with a non-item coordinate are projected as Record and then refused (#252 regression)","description":"Found in adversarial review of PR #252 (improvise-pr8t/nacd), head 47b166ee. Introduced by that PR.\n\n#252 makes App::non_item_refusal refuse a wire SetCell/ClearCell unless every coordinate is in Model::effective_item_names. The projection was not changed. In records mode, project::cell_id projects every measure cell of every stored record as CellId::Record. That includes a record whose category coordinate names no item. So the host now offers a cell as editable and then refuses the write. ADR 002 says the kind of identity is the permission, and command.md says the refusal asks \"the projection's own question, never a second derivation of it\". This is a second derivation, and it disagrees.\n\nSuch records exist, and first-party paths make them:\n- the script/`:` command `set-cell 5 Region/Mars _Measure/Revenue` (effect_cmds.rs SetCellCmd) adds no item;\n- a `.improv` `## Data` line naming an undeclared item (parse.rs sets the cell, adds no item);\n- drill commits after an item is removed (improvise-5mqm).\n\nProbe (reviewer's, not pushed): records mode (_Index Row, _Dim Column), Region={East}, _Measure={Revenue}, stored {Region=East,Revenue}=1 and {Region=Mars,Revenue}=5.\n- At 47b166ee, cell_id gives Record for (1,2), and unwritable returns \"Mars is no item of Region\". A caught-up wire SetCell is answered Rejected(MutationRefused).\n- Under the b114 rule (base 202e7eac), the same write is Accepted.\nThe TUI key path still edits the same cell, so the two front ends now disagree.\n\nFix shape: make the projection and the refusal ask one predicate. Either records-mode cell_id projects such a row as not-a-Record (e.g. Coordinate/Aggregate), or both call a shared \"names only items\" helper. Alternatively, close the root: set-cell and parse add the item, and 5mqm stops writing orphans. Pin it with an enumeration: every CellId::Record the host projects, in every view and in records mode, has unwritable == None.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:37Z","created_by":"agent-review-252","updated_at":"2026-09-23T23:59:37Z","labels":["adversarial-review","pr-252"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4v9f","title":"A browser rename onto an existing destination outside the grid window is refused RecordUnstated on every retry and reopen (milr)","description":"Found reviewing PR #251 at head 29d2adef (improvise-milr's fix). The server now checks a rename's destination (`records_at_stake`), and the client claims `seen`, which is the Basis it held at open. The server publishes that Basis only for the records in the client's current grid window (`published_basis(app, \u0026self.grid)`), and `BasisCache::apply` replaces the whole cache each time. So a destination outside the window is never claimed, and the rename is refused `RecordUnstated`.\n\n`context/code/clients.md` accepts that refusal as the right direction to err. What it does not say: **no retry, and no reopen of the editor, can ever succeed** unless the source row and the destination row are in the window at the same moment. The cursor must be on the source when the editor opens, so for records that sort far apart (for example with 100 regions, renaming Aardvark onto Zulu) a browser can never merge the two. The TUI key path can. After this PR the only way out is resizing the window.\n\nProbe (real Host, `three_records_host`, a viewport of 1 row, rename North onto the existing West): `basis records known: 1`, then `Rejected(RecordUnstated(West))` three times in a row, with the text retyped between attempts. North is never renamed.\n\nThis needs a decision, not necessarily code:\n- (a) Accept it and document it in `clients.md`, next to kndq's policy. It may even be the right answer if kndq decides to refuse such merges anyway.\n- (b) Let the host publish the destination's counter on demand.\n- (c) Make `RecordUnstated` on a rename's destination forceable.\n\nThe ticket is linked to kndq because kndq's policy decides whether merging onto an existing record should be allowed at all.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:05Z","created_by":"agent-review-221-251","updated_at":"2026-09-23T23:59:05Z","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-4v9f","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T23:59:12Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-4v9f","depends_on_id":"improvise-milr","type":"related","created_at":"2026-09-23T23:59:14Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pr3i","title":"Scratch repos inherit info/exclude from GIT_TEMPLATE_DIR","description":"tests/shipped_documents_load.rs's scratch repositories still take a user-level `info/exclude` from `GIT_TEMPLATE_DIR`. `git init` copies the template directory into the new `.git`, and `git rev-parse --local-env-vars` does not list `GIT_TEMPLATE_DIR`, so `git()` does not remove it. `GIT_CONFIG_GLOBAL=/dev/null` nulls `init.templateDir` in config, but the env var bypasses config.\n\nReproduced at PR #255 head 56ac72a2 (git 2.55.0): with `GIT_TEMPLATE_DIR=\u003cdir\u003e`, where `\u003cdir\u003e/info/exclude` holds `*.gz`, `a_gzipped_document_is_walked` FAILS at `git add` (\"Use -f if you really want to add them\"). This is the same symptom as improvise-53zz and improvise-vwjj, reached by a third path.\n\nFix candidates: `env_remove(\"GIT_TEMPLATE_DIR\")` in `run_git`, or `init --template=` with an empty value in `scratch_repo`. Test: rerun_with_env with GIT_TEMPLATE_DIR pointing at a dir whose info/exclude holds `*.gz`.\n\nAdjacent, not reproduced as user-level: the system gitattributes (`git var GIT_ATTR_SYSTEM`) is not disabled by GIT_CONFIG_NOSYSTEM; it needs GIT_ATTR_NOSYSTEM=1. GIT_ATTR_SOURCE fails loudly (\"bad --attr-source\"), not silently.\n\nFound in the adversarial review of #255.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:56:07Z","created_by":"agent-review-255","updated_at":"2026-09-23T23:58:13Z","started_at":"2026-09-23T23:58:13Z","lease_expires_at":"2026-09-24T00:03:13Z","heartbeat_at":"2026-09-23T23:58:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6u6s","title":"e265's enumeration has no non-contiguous group, so remark's run identity by header position (h0iv) is unguarded: a name-keyed rival passes all three tests","description":"Found reviewing PR #250 (improvise-e265) at d6e366dc.\n\nremark (prune_axis_items) keys each open run by its header's POSITION, so two runs under two equal headers stay two (context/code/core.md, the e265 section, claims this for h0iv). Nothing tests it. The TWO/THREE fixtures in layout.rs group_membership have every group's items contiguous under each parent, and a same-name run under a different parent is always separated by an outer-level change, which truncates the run stack anyway.\n\nRival, injected and run: key the input's open run by hash(level, cat_name, group_name) in place of the header's position. All three group_membership tests pass. A scratch test with the fixture Month = [Jan in Q1, Mar ungrouped, Feb in Q1] under North in Upper, which checks per-level run identity between consecutive surviving rows (not just innermost), fails at 'mask 0b101, collapsed []: run identity at level 1, row 1': pruning Mar merges North's two Q1 runs into one.\n\nThe ticket's own enumeration (300,580 cases) named non-contiguous groups; the PR's dropped them. Two gaps to close: (1) add a non-contiguous fixture, and ideally a hole level (a middle category with no groups); (2) assert the whole open-run stack per level, plus same-run-as-previous-row per level, not only innermost_group. The real code passes both on all five fixtures (TWO, THREE, NONCONTIG, HOLE, and an outer axis with two adjacent groups), 5,856 cases.\n\nCost note: the enumeration is already ~0.9-1.7s of improvise-core's lib binary (the rest is 0.16-0.18s, M4 Max under load avg 60-160). Adding fixtures should come with a cheaper construction: build each fixture's layout once per collapse subset and drive prune_axis_items with a keep predicate per mask, not two Workbook builds per mask.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:54:33Z","created_by":"agent-review-250","updated_at":"2026-09-23T23:57:15Z","started_at":"2026-09-23T23:57:15Z","lease_expires_at":"2026-09-24T00:02:15Z","heartbeat_at":"2026-09-23T23:57:15Z","dependencies":[{"issue_id":"improvise-6u6s","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-23T23:54:38Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-24lz","title":"pr-watch: a PR whose head moved and then merged exits 5, not 2; exit-5 ordering against the head checks is unpinned","description":"Found by the adversarial review of PR #253 at b13355c7 (improvise-1qaa).\n\npr-watch checks `state` AFTER the --expect check but BEFORE the head-pin check. The two head checks guard the same hazard: a verdict about a tree that no longer exists (PR #67 merged at a head that predated its fix). Exit 5 now wins over one of them and not the other:\n\n- merged at another head, with --expect aaaa: exit 2 (correct; also confirmed live on #216 with --expect deadbeef)\n- no --expect, first poll head a OPEN and running, next poll head b MERGED: exit 5, with the message \"PR 1 is MERGED, nothing to wait for\". The watcher never saw b, and the message does not name the head.\n\nSKILL.md and tooling.md describe exit 5 as \"someone else already merged it\", a normal answer the caller should hear as that. So a caller following the docs accepts a merge at a head nobody watched. That is exactly what the pin exists to refuse.\n\nFix: move the state check below the pin block. On the first poll the pin block only assigns, so \"ends on the first poll\" still holds. Consider printing the head in the exit-5 message too.\n\nNeither ordering is pinned by a test. Both rivals pass pr-watch-test.sh 50/50: state check moved above --expect, and state check moved below the pin. Add two cases: moved-then-merged, and merged-at-another-head with --expect.\n\nDoc claims to correct in the same change:\n(1) tooling.md says GitHub reports a MERGED **or CLOSED** PR as UNKNOWN/UNKNOWN permanently. Live, closed PR #74 reports CONFLICTING/DIRTY, so the CLOSED fixtures are an invented shape. The behaviour is unaffected, because state is read first.\n(2) SKILL.md says exit 5 \"ends on the first poll\". It ends on the first poll that SEES the PR closed, which may be mid-wait.\n","notes":"REGRESSION introduced by PR #253, not a pre-existing gap. Ran the same moved-then-merged fixture against the base script at 202e7eac: exit 2, 'head moved aaaaaaaaaaaa -\u003e bbbbbbbbbbbb during the wait'. At b13355c7: exit 5.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T18:33:04Z","created_by":"agent-review-253","updated_at":"2026-09-23T18:33:21Z","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8wy9","title":"App::restore's layout rebuild is unguarded: removing it leaves the workspace green","description":"Found by the adversarial review of PR #254 (improvise-4h8a) at head 67438b8f.\n\n`App::restore` ends with `self.rebuild_layout()`. `app.layout` is derived state, not an `AppSnapshot` field, so the new `snapshot_restore` tests never compare it.\n\nRIVAL THAT PASSES: delete the `self.rebuild_layout();` line from `App::restore`. `cargo test --workspace --no-fail-fast` stays fully green (39 binaries ok, exit 0).\n\nWHY IT MATTERS: `Host::undo_frame` calls `restore` and does not rebuild afterwards. `App::resolve_aggregate` and `record_in_layout` read `self.layout` through `layout_cell_keys`. So after a refused frame, the next frame's `Checked::against` would resolve surrogates against the grid of the frame that was rolled back, until some later `apply_effects` rebuilds it. The code is correct today. This is the same class as 4h8a: nothing stops a simplification from removing the rebuild.\n\nCHEAP FIX: in `snapshot_restore::restore_puts_back_every_field_the_snapshot_took` (crates/improvise-command/src/ui/app.rs), have the fixture change the grid's shape (for example, add an item under the added category, or set an axis), rebuild, and then after `restore` assert that the layout is equal to a freshly rebuilt one. `format!(\"{:?}\", app.layout)` works, or compare `row_count`/`col_count` and the labels. Then run the rival above and watch it fail.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:31:21Z","created_by":"Edward Langley","updated_at":"2026-09-23T23:54:31Z","started_at":"2026-09-23T23:54:31Z","lease_expires_at":"2026-09-23T23:59:31Z","heartbeat_at":"2026-09-23T23:54:31Z","labels":["adr-001","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cota","title":"bd-audit-ids skips an absent --assume-unchanged file as deleted: ls-files -d lists it, git status does not","description":"Found in adversarial delta review of PR #244 at head 33d563a9 (fix acecc63d, improvise-tdfd).\n\nThe tdfd fix skips an absent tracked file only when `git ls-files -d` lists it. But `ls-files -d` also lists an absent file marked `--assume-unchanged`. `git status` shows nothing for that file, so `commit -a` never records the deletion.\n\nThe audit therefore skips a file whose citations stay in HEAD, and exits 0. This is the same shape as tdfd: absent, not a deletion git will record, never read.\n\nReproduce in a throwaway repo:\n1. Commit `context/dead.md` citing an unknown id.\n2. Run `git update-index --assume-unchanged context/dead.md`, then `rm context/dead.md`.\n3. `git status --short` is empty. `git ls-files -d` prints `context/dead.md`.\n4. The audit prints \"all 1 cited ids resolve\" and exits 0.\n5. Control: without step 2 (a plain unstaged delete), the audit also exits 0. That is by design.\n\nRare, and CI checks out fresh, so P3. Fix: ask a deletion oracle that honours the index flags, such as `git diff --name-only --diff-filter=D`, which assume-unchanged hides. Alternatively, exclude `h`-flagged entries of `ls-files -v`. Add a case beside the skip-worktree case.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:15Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T00:02:35Z","started_at":"2026-09-24T00:02:35Z","lease_expires_at":"2026-09-24T00:07:35Z","heartbeat_at":"2026-09-24T00:02:35Z","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rq3f","title":"Browser Tab-commit's advance-right and the tile cursor's upper clamp are unasserted: breaking either passes the whole workspace","description":"Found in adversarial delta review of PR #246 at 790a33ba. This predates #246. The resolver code is untouched by that PR.\n\nTwo faults injected into `crates/improvise-protocol/src/resolve.rs` each pass the whole workspace (`cargo test --workspace --no-fail-fast`: 1945 passed, 0 failed):\n\n- `advanced`: `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))` changed to `Some((row, col))`. So `commit-and-advance-right` (Tab in an editor) in the browser no longer advances, and nothing notices.\n- `tile_cursor`: the upper clamp `(count - 1) as i32` changed to `count as i32`. So moving right from the last tile selects an index past the end, and nothing notices.\n\nFor comparison, five other resolver faults (page_rows, scroll offset, panel cursor, gg, G) are each caught by a named lockstep test. So these two are gaps in coverage, not a sign that lockstep is blind in general.\n\nFix: add a lockstep case for each, with a cursor at a nonzero index. Tab-commit should land where the host's CommitAndAdvance lands. l or Right on the last tile should agree with the host's MoveTileCursor.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:29Z","created_by":"agent-review-246b","updated_at":"2026-09-23T18:23:45Z","started_at":"2026-09-23T18:23:45Z","lease_expires_at":"2026-09-23T18:28:45Z","heartbeat_at":"2026-09-23T18:23:45Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xoxh","title":"lockstep.rs deliver() folds host-sent frames through fold_outgoing, and one merged-in test still folds its own commands through fold","description":"Found in adversarial delta review of PR #246 at 790a33ba.\n\n1. `crates/improvise-wasm-client/tests/lockstep.rs::deliver` folds the HOST's `ServerFrame::Commands` through `client.fold_outgoing(\u0026cmds)`. e0dec966 changed it from `fold` along with the 19 loops that fold the client's own resolved commands. But `deliver` models what the host sends, which `context/code/clients.md` says is `fold`'s door. The PR comment says \"the two remaining `fold` calls model what the host sends\", but `deliver` is a third host-side call, and it is on the wrong door.\n\n Latent today: `deliver`'s only caller (`switching_between_a_records_view_and_a_pivot_view_settles_both_ways`) sends view-only frames, so nothing is awaited and both doors settle immediately. With an awaited frame in flight, `fold_outgoing` would queue the host's relays at the BACK as the client's own frame, not directly behind the head as `settle_relay` does. That is the pre-jpba ordering, so a test built on `deliver` could not see a jpba regression.\n\n2. `renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host`, which arrived from gh/main in the merge 790a33ba, still folds the client's own resolved commands through `client.fold(cmd)`. So after the merge, \"every loop over the client's own commands uses fold_outgoing\" is not true at the head.\n\nFix: `deliver` goes back to `fold` for `ServerFrame::Commands`, and the renaming test uses `fold_outgoing`.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:19Z","created_by":"agent-review-246b","updated_at":"2026-09-23T18:29:43Z","started_at":"2026-09-23T18:29:43Z","lease_expires_at":"2026-09-23T18:34:43Z","heartbeat_at":"2026-09-23T18:29:43Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vwjj","title":"The scratch-repo tests still read the user's default excludes file (XDG git/ignore), so a *.gz there fails a_gzipped_document_is_walked despite GIT_CONFIG_GLOBAL=/dev/null","description":"Found reviewing PR #248 (improvise-53zz) at head f5cfc7ed.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, which stops git reading the user's global and system config files. It does not stop git reading the user's **default** excludes file. When `core.excludesFile` is unset, git falls back to `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` when XDG_CONFIG_HOME is unset. That fallback does not depend on any config file, so both variables leave it in force. It is also the most common place for a global ignore, because it needs no config line.\n\nMeasured on git 2.55.0 at f5cfc7ed. A probe test re-ran `a_gzipped_document_is_walked` through the PR's own `rerun_with_env` with `XDG_CONFIG_HOME=\u003ctmp\u003e` and `\u003ctmp\u003e/git/ignore` containing `*.gz`. The child failed at `git add`: \"The following paths are ignored by one of your .gitignore files: docs/model.improv.gz\". Setting `HOME=\u003ctmp\u003e` with `\u003ctmp\u003e/.config/git/ignore` fails the same way. That is 53zz's symptom 2 on the path the fix does not cover. The PR's test sets `core.excludesFile` explicitly inside a config file, so it cannot see this.\n\nFix options, cheapest first: pass `-c core.excludesFile=/dev/null` in `run_git` (a `-c` argument, which `--local-env-vars` does not strip), or point `XDG_CONFIG_HOME` and `HOME` at an empty temp dir. Also consider `core.attributesFile`, which has the same XDG default. Rival: run the gzip shape test in a child with `XDG_CONFIG_HOME` pointing at a dir whose `git/ignore` holds `*.gz`, and assert it passes.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:17:06Z","created_by":"agent-review-248","updated_at":"2026-09-23T18:24:02Z","started_at":"2026-09-23T18:24:02Z","lease_expires_at":"2026-09-23T18:29:02Z","heartbeat_at":"2026-09-23T18:24:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-metf","title":"prune_empty evaluates every cell with a fresh fold memo","description":"Found reviewing #247 (improvise-h6z1): GridLayout::prune_empty (view/layout.rs ~620) evaluates each cell with its own fresh memo — the same shape h6z1 fixed in grid_body, on the same keypress path. Measure first on the folded-chain bench; share one memo per build if it pays.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:14:55Z","created_by":"orchestrator","updated_at":"2026-09-24T00:05:23Z","started_at":"2026-09-24T00:05:23Z","lease_expires_at":"2026-09-24T00:10:23Z","heartbeat_at":"2026-09-24T00:05:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0jw5","title":"Nothing guards that viewport::grid_body shares one FoldMemo: a per-cell fresh memo there passes the whole suite","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc). The PR's whole effect is grid_body making one FoldMemo and passing it to cell_value and column_total. Rival: in viewport.rs cell_value, replace the memo with FoldMemo::default() per cell. cargo test --workspace --no-fail-fast: zero failures. a_fold_row_evaluates_each_formula_once_across_the_row calls GridLayout::evaluate_in directly with a memo of its own, so it pins the layout-level API and not the build that is supposed to use it; the totals' memo is likewise unguarded. Only benches/grid_projection.rs (folded chain ~18k: 1.19s -\u003e 0.52s measured) can see a regression, and it is not a gate. Add a test in improvise-command that counts FOLD_FORMULA_EVALUATIONS (or an equivalent observable) across one grid_body call on a folded chain and asserts 9 per fold row, with the per-cell rival shown failing. Note the counter is #[cfg(test)] pub(crate) in improvise-core, so it is not visible from improvise-command as-is.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:31Z","created_by":"agent-review-247","updated_at":"2026-09-23T18:24:44Z","started_at":"2026-09-23T18:24:44Z","lease_expires_at":"2026-09-23T18:29:44Z","heartbeat_at":"2026-09-23T18:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pntu","title":"FoldMemo's key omits the aggregation and the covers predicate: one memo shared across two covers returns the first answer","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc); also ChatGPT Codex's P2 on that PR. FoldMemo is pub and keyed by (CellKey, span.over). evaluate_covering_in / evaluate_spanning_in are pub and take any agg and covers. Demonstrated: margin_book(['R = Revenue / Cost']), key {Month=Q1, _Measure=R}, Aggregation::Exact. evaluate_covering_in with covers=all then with covers=hide-Feb on ONE memo returns 5.0 for the second call; a fresh memo returns 2.0. Not reachable from viewport::grid_body today (agg and covers are constant per build), which is why the suite is green. The PR includes span in the key precisely because the _in functions are public; agg and covers are the same argument left unanswered. Fix shape: bind agg+covers into the memo (e.g. a per-build evaluator object that owns memo, agg and covers, so the _in functions cannot be handed a mismatched pair), rather than widening the key with a closure identity. Test: the one above, asserting the second call equals a fresh evaluation.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:20Z","created_by":"agent-review-247","updated_at":"2026-09-23T18:24:44Z","started_at":"2026-09-23T18:24:44Z","lease_expires_at":"2026-09-23T18:29:44Z","heartbeat_at":"2026-09-23T18:24:44Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nmm5","title":"Lockstep never exercises a refusal: a ClientState refusal-ordering regression is visible only to client.rs unit tests","description":"Found while fixing improvise-3kfc (PR #246). tests/lockstep.rs now folds the client's own commands through fold_outgoing, which is the right door, but it never refuses a frame. So a regression in how ClientState orders a refusal against queued relays or view-only frames is caught only by the client.rs unit tests and the two Host-level jpba tests. Add at least one lockstep case that sends two frames, has the host refuse the first, and compares client and mirror after both answers.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T18:10:26Z","created_by":"orchestrator","updated_at":"2026-09-23T18:10:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tdfd","title":"bd-audit-ids silently skips a tracked file absent by skip-worktree or sparse checkout, not only a deleted one","description":"PR #244 head e2649820 (ChatGPT Codex P2, confirmed). The deleted-file filter keeps a path only if `[ -e \"$f\" ] || [ -L \"$f\" ]`, so ANY tracked path absent from the working tree is dropped, not only a deleted one.\n\nReproduced in a detached worktree at e2649820: context/adr/001-concurrent-edits.md holds the only citation of improvise-k2i4. With a stub bd (BD_AUDIT_BD) that knows every other cited id, the audit exits 1. After marking that file skip-worktree and moving it out of the tree, it exits 0 (\"all 498 cited ids resolve\").\n\n`ls-files -d` does NOT list the skip-worktree path, so skipping only what `ls-files -d` reports, and treating any other missing tracked path as exit 4, discriminates. Needs a skip-worktree case beside the deleted-file case.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:48Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:16:08Z","started_at":"2026-09-23T18:16:08Z","lease_expires_at":"2026-09-23T18:21:08Z","heartbeat_at":"2026-09-23T18:16:08Z","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fd0j","title":"pr-reviews-test watchdog needs bash 5.1 but checkers.sh pins only major 5","description":"PR #237 head 785202b8 (ChatGPT Codex P2, confirmed). bounded_reviews runs `wait -n -p done \"$pid\" \"$dog\"`; the -p option is bash 5.1. find_modern_bash in scripts/checkers.sh accepts any BASH_VERSINFO[0] \u003e= 5, so a bash 5.0 host is accepted and the suite dies (`done` unbound under set -u).\n\nDemonstrated shape under macOS /bin/bash 3.2 run directly: 2 passed, 23 failed, where gh/main's suite is 25/25 under 3.2. CI (5.2) and nix (5.3) are fine, so this is a contract mismatch, not a live CI failure.\n\nFix: raise find_modern_bash's floor to 5.1 (with a checkers-test case for 5.0), or detect the watchdog winner without -p.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:47Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:16:06Z","started_at":"2026-09-23T18:16:06Z","lease_expires_at":"2026-09-23T18:21:06Z","heartbeat_at":"2026-09-23T18:16:06Z","labels":["adversarial-review","pr-237"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bbwa","title":"check-ci-graph counts a --ci step in a job that needs: a job which can be skipped; the dependent job is skipped and a skipped check reads as passing","description":"At PR #228 head d6706774: add a job 'gate' with job-level 'if: false' (no checkers.sh step, so nothing disqualifies it), give kb 'needs: [gate]', and add gate to pages-deploy's needs. check-ci-graph exits 0 with all 9 suites counted, but on a PR GitHub skips kb (its dependency was skipped) and a skipped required check counts as success. Same class as improvise-tvte (a condition written around the call), one hop away: the disqualifiers are read only on the counted job. build/js/kb have no needs: today. Cheapest close: treat a job-level needs: as a disqualifier for the counted step, or require every needed job to be unconditional.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:22Z","created_by":"agent-review-tooling4","updated_at":"2026-09-23T18:02:24Z","started_at":"2026-09-23T18:02:24Z","lease_expires_at":"2026-09-23T18:07:24Z","heartbeat_at":"2026-09-23T18:02:24Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nacd","title":"Wire SetCell on a stored record with a _Measure that is no item (unknown name, or a parent-only group) is accepted and stores a non-item coordinate","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing: the PR does not introduce it, and it does not close it.\n\nPR #235's rule is \"no coordinate names a group\". The invariant it protects\n(improvise-gw18) is wider: \"every coordinate names an item\". Two writes fall in\nthe gap. Both are accepted at 5c19fdd4 and store a non-item coordinate:\n\n- A caught-up wire SetCell on the stored record `{Month=Feb}` with measure\n `Bogus`, which is no item and no group of `_Measure`. It stores\n `Month=Feb, _Measure=Bogus = 7`.\n- The same write with measure `Totals`, where `Totals` is a PARENT group\n (`Group::new(\"Money\").with_parent(\"Totals\")`) that holds no item directly.\n `items_in_group(\"Totals\")` is empty, so `group_refusal` does not see it.\n It stores `_Measure=Totals = 7`.\n\nNeither cell is drawn in any pivot view, which is the gw18 shape: invisible data\nthat persists through format_md and counts in records mode. The record half is\nsafe, because an unknown record does not resolve. The measure half is unchecked\nwhenever the record is stored.\n\nThreat model is the same as b114/xur5: a hand-rolled or stale client. No honest\nclient composes these writes.\n\nProbe: `review_235::probe_parent_only_group_and_unknown_measure` (reviewer's,\nnot pushed). Fix shape: refuse a `_Measure` coordinate that is not in\n`measure_item_names()` (items plus formula targets; formula targets are refused\nanyway by the Derived rule). Or generalise `group_refusal` to \"names no item of\nits category\".\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T18:11:47Z","started_at":"2026-09-23T18:11:47Z","lease_expires_at":"2026-09-23T18:16:47Z","heartbeat_at":"2026-09-23T18:11:47Z","labels":["adversarial-review","pr-235"],"comments":[{"id":"ec47fc8d-7311-511f-a2ee-dd5c6345723d","issue_id":"improvise-nacd","author":"agent-review-221-251","text":"Reachable by an ordinary race since PR #274 (seen at 3dad3b09), not only by a hand-built frame. A captures a keyboard editor on North/Cost. B sends RemoveItem(_Measure, Cost), accepted and delivered. A presses Enter. The captured OpenedCell still names measure Cost, the record basis passes because North's version did not move, and the host Accepts SetCell North/Cost = 42 with Cost no longer an item. The old cursor guard would not have caught it either: the reset leaves the cursor on a Record.","created_at":"2026-09-24T03:24:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-vwc7","title":"An item may share its name with a group of its category, so a key naming it is ambiguous: a wire write to the item is refused in rows and accepted in _Measure","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing model gap. #235 makes it matter, and PR #221 (improvise-fdk1) will add a second way in.\n\n`Category` lets an item and a group share a name. `add_item` and\n`add_item_in_group` check only `items`, and `add_group` checks only `groups`.\nOnce they share a name, a key naming it is ambiguous: `Month=Q2` is both the\nitem Q2's row and the collapsed group Q2's fold row.\n\nThe wire behaves inconsistently, as measured at 5c19fdd4 with\n[AddItem, SetCell] frames that carry the SetCell's basis:\n\n- `_Measure`: AddItem `Money` (Money is already a group of Revenue and Cost),\n then SetCell `{Feb, Money}`. The frame is **accepted** and stored.\n- Row category: in `host_on_a_fold` (Q2 collapsed), AddItem `Month=Q2`, then\n SetCell `{Q2, Revenue}`. The frame is **refused** (`MutationRefused`) and\n undone. The new record is unstored, so `aggregate_refusal` finds the key by\n position, finds the FOLD row, and sees an Aggregate. A write to a real item\n was refused because the fold shares its key.\n\n#235's `group_refusal` exempts a name that is also an item, which is the right\ncall given the model. But the root is the namespace, not the refusal.\nimprovise-fdk1's `RenameRecordCoordinate` (PR #221) creates the target item\n\"if need be\", so a rename onto a group's name will create exactly this\nnamesake. So will the TUI's records-mode category edit.\n\nDecide one of: (a) the model refuses an item named like a group of its category,\nand a group named like an item (smart constructors); or (b) keys disambiguate a\nfold from an item. (a) is the smaller change. Probe:\n`review_235::probe_add_item_then_write_in_one_frame` (reviewer's, not pushed).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T16:16:42Z","labels":["adversarial-review","pr-235"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pr8t","title":"#235's same-name-item exemption in group_refusal is unpinned: removing it refuses a real item and the workspace stays green","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4.\n\n`App::group_refusal` exempts a name that is also an item of the same category\n(`!category.items.contains_key(name)`). Nothing pins that exemption. With it\nremoved (`let group = true \u0026\u0026 !items_in_group(name).is_empty()`), the full\nworkspace suite stays green (`cargo test --workspace --no-fail-fast`, 0 failed).\n\nUnder that rival, a real item is refused. Fixture: `_Measure` has Revenue and\nCost in group Money, and ALSO an ungrouped item named Money\n(`Category` permits that: `add_item` checks only `items`). The stored cell\n`{Month=Feb, _Measure=Money}` is a legitimate item cell. At 5c19fdd4 a caught-up\nwire SetCell on it is accepted (correct). With the rival it is refused.\n\nFix: one test that writes to an item whose name is also a group of its category\nand asserts Accepted and the stored value. The reviewer's probe is\n`review_235::probe_item_named_like_its_categorys_group_is_writable` (not pushed).\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:40Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T18:11:44Z","started_at":"2026-09-23T18:11:44Z","lease_expires_at":"2026-09-23T18:16:44Z","heartbeat_at":"2026-09-23T18:11:44Z","labels":["adversarial-review","pr-235"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-su4u","title":"A minibuffer refusal (formula parse error) vanishes on the first correcting keystroke under #233's status expiry","description":"Found reviewing PR #233 (improvise-cq3d) at head d9ea5946.\n\n#233 clears `status_msg` at the start of every TUI keypress (`draw.rs::press`). In a\nminibuffer mode `minibuffer_line` draws the message on the right of the prompt, and\nsince improvise-v4hg a formula that does not parse keeps the user in `FormulaEdit`\nwith the error. The error now disappears on the first correcting keystroke:\n\n- type `Total = (` in the formula editor, press Enter: the row reads\n `formula: Total = (▌ ... Formula error: --\u003e 1:10`\n- press Backspace once: the row reads `formula: Total = ▌`, and the error is gone.\n\nThe user is shown the error once, but it is gone before they finish fixing the thing\nit describes. The same applies to any refusal that leaves the user in a minibuffer.\nBefore #233 the error stayed while they edited, and then stayed forever (the cq3d bug).\n\nPossible rule: in a text-entry mode, expire the message only when the key leaves the\nmode, or only on the commit key. Decide whether that is worth the extra rule; it is a\ndesign choice, not a crash. Reproduce it through `press`, not `handle_key`, because\nthe expiry is in the TUI loop only.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:43Z","created_by":"agent-review-233-242","updated_at":"2026-09-23T18:23:44Z","started_at":"2026-09-23T18:23:44Z","lease_expires_at":"2026-09-23T18:28:44Z","heartbeat_at":"2026-09-23T18:23:44Z","labels":["adversarial-review","area:tui","pr-233"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-m6op","title":"Cross-instance Debug fingerprint: Model.measure_agg's sort is pinned by no test","description":"Found reviewing PR #241 (head 3a4b0eac). Model's Debug sorts measure_agg (HashMap\u003cString, AggFunc\u003e) into a BTreeMap so two equal documents print alike. No committed test sees it: replacing .field(\"measure_agg\", \u0026aggs) with the raw HashMap left cargo test --workspace --no-fail-fast green in 3 of 3 runs. The only cross-instance comparison, a_model_command_lands_where_its_keypress_lands, has an empty measure_agg, so a HashMap of zero entries has one order (the same vacuity djcn fixed for View/DataStore). formula_cache's sort IS pinned (recomputing_formulas_leaves_the_model_fingerprint_alone fails when it is unsorted). A reviewer scratch test with two measure_agg entries failed 3/3 under the injection. Fix: give the pivoted() fixture two or more measure_agg entries (and ideally a formula so formula_cache is non-empty across instances too), then re-inject.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","updated_at":"2026-09-23T16:05:41Z","dependencies":[{"issue_id":"improvise-m6op","depends_on_id":"improvise-djcn","type":"discovered-from","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cwfj","title":"The remaining set_status declines: panel.rs (3) and keymap dispatch's arg-error arm should refuse","description":"Remainder of improvise-i3o5 after PR #236. cmd/panel.rs:708 'Move cursor to a category header to change axis', :726 'No category selected…' and :818 'No category to delete' decline with set_status, so they exit 0 when run headless. command/keymap.rs::dispatch writes set_status(e) for a bound key whose constructor returns Err. ORCHESTRATOR RULING: that Err is a refusal (cli.md §10), so dispatch should refuse(e). Before converting dispatch, check nothing depends on a bound key's Err leaving last_batch_refused false. Assigned to agent-refuse-fu.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:47:49Z","created_by":"orchestrator","updated_at":"2026-09-23T15:47:59Z","started_at":"2026-09-23T15:47:59Z","lease_expires_at":"2026-09-23T15:52:59Z","heartbeat_at":"2026-09-23T15:47:59Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tvte","title":"check-ci-graph counts a checkers.sh invocation that never runs on a PR: step/job if: conditions and shell control flow are ignored","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py counts an invocation that never runs on a PR. The count is silent, so a suite can be reported as run in CI when it is not. Two sources:\n\n1. **YAML conditions are ignored.** A step's `if:` and a job's `if:` are never read. A suite whose only invocation is in a `- if: failure()` step, or in a job gated like pages-deploy (`if: github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`), counts as run on every PR.\n2. **Shell control flow is ignored.** Each of these counts the suite, although it never runs:\n - `true || ./scripts/checkers.sh X`\n - `false \u0026\u0026 ./scripts/checkers.sh X`\n - `exit 0` on the line before the invocation, in a block\n - `if false; then` … `fi` around the invocation\n - the invocation inside a shell function that is never called\n\nEach was demonstrated with a probe over suite_verdict/suites_run, requiring pr-watch where the shape is its only invocation.\n\nThe shell half cannot be fully closed by static analysis. The YAML-condition half can be: refuse, or refuse to count, any invocation in a step or job that carries an `if:`. Whatever is not closed should be stated as a residual in context/code/tooling.md. At present that section says only what is caught.\n\nLower priority than the multi-line/heredoc fail-open. Every shape here needs someone to write a condition around the invocation. That filed ticket needs only a prose mention of the script.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:52Z","created_by":"agent-review-228b","updated_at":"2026-09-23T15:48:09Z","started_at":"2026-09-23T15:48:09Z","lease_expires_at":"2026-09-23T15:53:09Z","heartbeat_at":"2026-09-23T15:48:09Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-szi8","title":"Protocol version in the client/server handshake","description":"The user decided on PR #320 (ADR 005, question 9): \"yeah, accept and add a ticket for handshake version.\"\n\nProblem: the client/server protocol has no version handshake. A deployed browser page can talk to an improvise-ws-server built from a different commit, and nothing says so. ADR 005 changes how a cell value is spelled on the wire: exact values become strings such as \"12.50\" and \"10/3\", and inexact values become \"#i…\". Across such a version skew:\n- an old peer reading a new value refuses the whole frame, loudly;\n- a new peer reads an old peer's bare JSON number as inexact, silently. That old peer's typed 0.1 then compares unequal to every typed 0.1 on the new side, though both look identical.\n\nAsk: add a protocol version to the handshake (ClientFrame::Viewport / the Snapshot), and refuse or warn on a mismatch before any value crosses.\n\nAcceptance:\n- A mismatched peer is told so at the handshake, before any frame carrying values is exchanged.\n- A test pins both directions, old→new and new→old.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:16:53Z","created_by":"agent-adr005","updated_at":"2026-09-25T17:16:53Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.12","title":"minibuffer.* inherits the status badge through mode, so a theme omitting a prompt face draws a badge; decide overlay-vs-replace before theme upload","description":"Found in the post-merge review of PR #319 (merged head 3583359a). Needs a decision\nbefore improvise-nn4s.7 (theme upload) lands, and possibly the user's.\n\nAfter Codex's P2 fix, `minibuffer.\u003centry\u003e` inherits `mode.text-entry`, which inherits\n`mode`, as ADR 004 says. But `mode` is the status-line badge: `Named(Black)` on\n`Named(BrightBlack)`. The prompt is accent-on-`Indexed(235)`, bold. So the shipped theme\nstates every colour/weight attribute on all seven minibuffer leaves, and the parent\ncontributes nothing but slant, underline and reverse.\n\nClause 9 promises that a theme may safely *omit* a face and inheritance supplies it.\nUnder a replace-semantics upload, a user theme that omits `minibuffer.formula` gets the\nbadge colours: black on dark grey, not bold. It does not get a prompt. Two readings fix it:\n- (a) an uploaded theme OVERLAYS `Theme::shipped()` face by face, so omission falls back\n to the shipped leaf. That is a code decision for nn4s.7, but `Theme` should then say so.\n- (b) the ADR chain is revisited, so the prompt and the badge stop sharing a parent.\n That is the user's call, and it is a published-name/parent change (clause 9).\n\nNot a defect today: the only theme is the shipped one, and it reproduces the literals.\nThe ask is that nn4s.7 not ship without choosing (a) or (b), and a test that an uploaded\ntheme omitting a minibuffer face still draws a prompt.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:04Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:04Z","labels":["adversarial-review","needs-user","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.12","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.11","title":"Stale pointers to axis_fg/minibuf_fg/AxisColor after PR #319: web/style.css, repo-map, kb.pl","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\nThese references still name what #319 deleted (`axis_fg`, `minibuf_fg`, `AxisColor`,\n`Axis::color`):\n- `web/style.css:677-679`, the comment over `#minibuffer.green`, says the class\n resolves \"exactly as `draw.rs::minibuf_fg` resolves it for the TUI\".\n- `context/repo-map.md` (orchestrator-owned): the How-to-find row \"Change an axis label,\n color or cycle\" (colors resolve in `draw.rs::axis_fg`), gotcha 26 (`color() -\u003e AxisColor`,\n `draw.rs::axis_fg`), and the File Inventory line for `view/axis.rs` (\"AxisColor\").\n `improvise-protocol/src/face.rs` has no inventory line.\n- `prolog-sessions/kb.pl` (orchestrator-owned, `scripts/kb` only):\n `location(axis_presentation, ...)`, which says the color/AxisColor live on Axis;\n `location(axis_color_resolution, ...)`, which says axis_fg is in draw.rs; and\n `invariant(semantic_palette_at_the_core_boundary, ...)`, whose precedents are\n MinibufColor -\u003e minibuf_fg.\n- `TAGS:1235` still lists `mode_style`. It is a generated file, so regenerate or ignore.\n\nAlso: improvise-nn4s.1 and improvise-nn4s.2 are still in_progress although #319 merged.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T17:09:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:09:02Z","labels":["adversarial-review","docs","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.11","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:09:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.9","title":"every_face_reaches_the_terminal_with_every_attribute_it_states checks only BOLD and uses terminal_colour as its own oracle","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\n`src/draw.rs::face_tests::every_face_reaches_the_terminal_with_every_attribute_it_states`\ndoes not test what its name says:\n- It checks fg and bg against `terminal_colour`, which is the function under test, so a\n wrong slot mapping cannot fail it. One example is `AnsiSlot::White =\u003e Color::White`,\n the ratatui naming trap `context/code/tui.md` warns about.\n- Of the four modifiers it checks only BOLD.\n- The shipped theme states no italic, underline or reverse, so\n `the_shipped_theme_reproduces_every_migrated_literal` cannot see them either.\n\nInjected fault: in `face_style`, map `Slant::Italic` to `Modifier::UNDERLINED` and\n`reverse` to `Modifier::DIM`. `cargo test --workspace --no-fail-fast` stays fully green.\nThis matters before improvise-nn4s.6, whose whole mechanism is `reverse`.\n\nFix: build a synthetic `ResolvedFace` per attribute, or a non-shipped `Theme` that\nstates each one, and assert the exact `Modifier` bit. Pin `terminal_colour` against a\nliteral table of the 16 slots (ANSI 7 = ratatui Gray, 8 = DarkGray, 15 = White) rather\nthan against itself.\n\nSame pass, a nit: `Face::of_mode`'s `.unwrap_or(Face::ModeTextEntry)` and\n`draw_bottom_bar`'s matching fallback are unreachable. Remove them, or make them\n`expect`, so a disagreement between `TextEntry::of` and `of_mode` fails loudly instead\nof falling back silently.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:08:58Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:08:58Z","labels":["adversarial-review","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.9","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:08:58Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.8","title":"The wire half of ADR 004's category-add exception is unpinned: MinibufColor::Yellow on category_add passes the whole workspace","description":"Found in the post-merge review of PR #319 (merged head 3583359a).\n\nADR 004 clause 8's one declared exception has two halves. The TUI half is pinned by\n`the_category_add_prompt_draws_as_the_item_add_prompt_does`. The wire half is not.\n`AppMode::category_add` now sends `MinibufColor::Green`, and the browser's class comes\nfrom it (`web/app.js:311`).\n\nInjected fault: set `category_add`'s `color` in `crates/improvise-protocol/src/lib.rs`\nback to `MinibufColor::Yellow`. `cargo test --workspace --no-fail-fast` stays fully green.\nNo Playwright spec asserts the minibuffer class either. So the browser could go back to\nyellow while the TUI stays green, and nothing would notice.\n\nThere is a second hazard here. The TUI no longer reads `MinibufferConfig.color`: it\nderives the face from the mode variant. The browser still reads `color`. That means two\nderivations of one fact until improvise-nn4s.4 lands, and nothing holds them in step.\n\nFix, the smallest that discriminates: a protocol test asserting that for every\ntext-entry `AppMode`, `minibuffer().color` agrees with the shipped theme's\n`minibuffer.\u003centry\u003e` foreground. It must include category-add == item-add. It is deleted\nwith the field in nn4s.4.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T17:08:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T17:08:55Z","labels":["adversarial-review","pr-319"],"dependencies":[{"issue_id":"improvise-nn4s.8","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T17:08:55Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9xy9","title":"Cargo.nix has no freshness gate: dev-dependency test-support edges drifted on main unnoticed, and the nix-build job builds only #improvise","description":"Found by the adversarial review of PR #318 (head 689480bf).\n\nOn gh/main (c91cc055), Cargo.nix had drifted from the Cargo.toml files, and nothing noticed. Five dependency edges ask for improvise-core's or improvise-protocol's `test-support` feature:\n- the root crate's improvise-protocol;\n- improvise-command's improvise-core and improvise-protocol;\n- improvise-wasm-client's improvise-protocol.\n\nMain's Cargo.nix mentions `test-support` three times. PR #318 regenerated it with crate2nix for unicase, and four more `test-support` edges appeared that had nothing to do with that PR.\n\nWhy CI misses it: the `nix-build` job runs only `nix build .#improvise`. That builds the non-test crate graph, so a missing dev-dependency feature edge never fails. A missing *normal* dependency does fail, which is why #318 had to regenerate for unicase. No job checks that Cargo.nix is what crate2nix would generate from the current Cargo.lock and manifests.\n\nFix: a CI step, or a checkers suite, that regenerates Cargo.nix and diffs it (`crate2nix generate` then `git diff --exit-code Cargo.nix`). Alternatively, build the crate2nix test derivations. Either way, inject a stale edge and watch the check fail.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:19:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:19:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.17","title":"Headless import suite misses three boundary faults: every bad formula named, CSV float measures, :import-json on CSV","description":"Found in the adversarial review of PR #321 at efaa5050. The headless import suite (tests/headless_cli.rs) does not see three faults at the CLI boundary, although the user treats that boundary as a product and context/code/cli.md section 13 states its contract. Each fault below was injected alone, and the whole workspace suite was run:\n\n1. **Every bad formula is named.** cli.md section 13 says: \"Every bad formula is named on stderr\". The headless test passes one bad and one good formula, so naming only the first bad one (the author's rival R5) is caught only by the io unit test `a_formula_that_does_not_parse_stops_the_build_and_is_named`. The fix is cheap: pass two bad `--formula`s in `an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing`.\n2. **A CSV float measure.** With `csv_parser::number_in`'s f64 branch disabled (integers only), `1000.50` and similar measures are silently dropped. Only io unit tests failed (4 csv_parser tests and one wizard test). No headless or root integration test imports a CSV with a decimal measure and checks the value.\n3. **`:import-json` / ModelCommand::ImportJsonHeadless on a CSV.** With its CSV branch changed back to `ImportPipeline::new(Value::Array(recs.to_vec()))`, the JSON constructor, only `import_headless_refuses_records_that_collide` failed, and it failed only incidentally, because Revenue stopped being a measure. No test for this path asserts that a numeric CSV column is proposed as a Measure, or that a category's text is kept verbatim.\n\nThe wizard-only rivals R6 and R8 have no headless surface, so a unit test is the right level for them. They are not part of this ticket.\n","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-headless-import","owner":"el-github@elangley.org","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:20:11Z","started_at":"2026-09-25T06:20:11Z","lease_expires_at":"2026-09-25T06:25:11Z","heartbeat_at":"2026-09-25T06:20:11Z","labels":["headless","import","tests"],"dependencies":[{"issue_id":"improvise-8jk6.17","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T06:15:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tpm8","title":"import --extract on a field with no detected date format is silently ignored and exits 0","description":"`improvise import --no-wizard --extract F:Year` on a field F with no detected date format does nothing and exits 0 without a word. Examples are a years-only column (`2023,2024,2025`, forced with `--time`), or a column where one value fails to parse.\n\nFound by the adversarial review of PR #322 at 6b910889 (years.csv, `--time Year --extract Year:Year --extract Year:Month`). The output was one flat category of the raw years, and there was no warning. That result is fine; the silence is not, because the user asked for a component and got none. The behaviour is not new with #322. Sibling of improvise-31oe.2 and improvise-8jk6.5 (other silent headless no-ops).\n\nExpected: a warning naming the field and saying no date format was detected, so the extraction was not applied. It could also be an error, matching how 8jk6.5 is resolved.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:25Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-tpm8","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-d0n6","title":"Hierarchical date import: an unparsed raw value shares the item namespace with generated months/years and silently merges (latent)","description":"From ChatGPT Codex's P2 on PR #322 (review at 195f3b44, still applicable at 6b910889). The hierarchical date import (improvise-8jk6.3) keeps a value the detected format cannot parse as a raw, ungrouped item in the SAME category as the generated items. So a raw value spelled like a generated item or group (`2024-01`, `2024`) merges with it. `Category::add_item_in_group` returns the existing item whichever came first, so either the raw record lands in the grouped month, or the month's later records land in an ungrouped raw item. The warning \"kept its raw value … ungrouped\" is then inaccurate.\n\nLATENT today: `detect_date_format` requires EVERY distinct value to parse, and `date_format` is written nowhere else outside tests. So no shipped input reaches the unparsed branch. The review checked this with a CSV mixing valid dates and `2024-01`: no format was detected, and the column became a plain category. It becomes live the moment a format can be chosen or detected from a sample.\n\nFix before that: refuse, fall back to Split for the column, or give raw values a namespace that cannot collide. Add a mixed valid/invalid regression whose raw spelling equals a generated item and a generated group. Related: improvise-vwc7 (an item may share its name with a group of its category).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:23Z","labels":["import"],"dependencies":[{"issue_id":"improvise-d0n6","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nuo7","title":"An empty formula SUM evaluates to -0.0 and renders as -0.00","description":"A formula `SUM(...)` over an empty match evaluates to -0.0, and the grid and CSV export render it as `-0.00`. Rust's `f64` `Sum` starts from -0.0, so an empty iterator sums to negative zero.\n\nReproduction, found by the adversarial review of PR #322 at 6b910889: `Y24 = SUM(Revenue WHERE Date = |2024|)` in a document where no record has `Date=2024`. `export-csv` writes `-0.00` in every row. The #322 author saw it too (PR body, \"a second small ticket\").\n\nExpected: `0.00`. Gotcha 7 says an empty SUM is `0`. Fix at the aggregation site, e.g. start the fold from +0.0, and pin it with a test on the rendered text, not on `== 0.0`: -0.0 == 0.0 is true, so an equality assertion is vacuous here.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T06:09:21Z","created_by":"Edward Langley","updated_at":"2026-09-25T06:09:21Z","labels":["core","formula"],"dependencies":[{"issue_id":"improvise-nuo7","depends_on_id":"improvise-8jk6.3","type":"discovered-from","created_at":"2026-09-25T06:09:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3mhc","title":"core.md §19 says z z leaves the view as it was, which is false for a group with Expanded(n\u003e=2)","description":"Found reviewing PR #317 (improvise-felo.6). context/code/core.md §19 ('z writes one fold, or none') says 'So z z leaves the view as it was'. The PR body says the same. The sentence after it concedes the case that makes that false. Probe on a Year\u003eQuarter tree with expanded(1): Date, where Y25 carries Expanded(2): z on Y25 writes Collapsed, and the second z removes the fold, since Y25 inherits open. Q1b under Y25 was open and is now closed. The 2 is lost, and the view is visibly different after z z. The same pair of presses also drops an explicit Collapsed that agrees with what the group inherits (decision 3 on the PR). The screen is unchanged then, but a later expanded(N): Cat now opens that group. Fix: say what z z does preserve, which is the toggled group's own open or closed state, and name both exceptions. Or, if the user wants z z to be an identity, keep the displaced fold. That is the PR's user question 2.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:33:46Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:33:46Z","labels":["core","docs"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h6o6","title":"Measure how many generated .improv files a Refusal now skips","description":"Found reviewing PR #317 (improvise-felo.6). generated_file_parses now accepts a typed persistence::parse::Refusal (a HierarchyCycle or a FoldConflict). generated_file_roundtrips skips it through prop_assume!(result1.is_ok()). The property still fails on any parse error that is not a Refusal, so it still discriminates. What it lost is coverage. A refused file stops at build_category or build_view, so the rest of that file (formulas, data, later views) is never checked, and its round trip is never run. That covers self-parent lines like \u003e ||[||], and a collapsed: plus an expanded: line on one target, both empty names being common in the walk. The rate was not measured in review. Want: count Ok, Refusal and Err over a deterministic run, and state the rate in context/test-evidence. If it is more than a few percent, make the generator avoid the two shapes (for example, distinct names on a \u003e line), so a Refusal stays rare rather than routine.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:59Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:59Z","labels":["io","tests"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qypb","title":"View::expand_depth is a pub usize map, so expanded(0): Cat is still writable","description":"Found reviewing PR #317 (improvise-felo.6) at 8d3230e6. That commit answered Codex's P2 by making GroupFold::Expanded carry a NonZeroUsize, so a group fold of 0 can no longer be built. The category form is the same hole and is still open. View::expand_depth is a pub HashMap\u003cString, usize\u003e, and format_md writes every entry as expanded(N): Cat. Probe: wb.active_view_mut().expand_depth.insert(\"Date\".into(), 0) makes format_md write expanded(0): Date, and parse_md then refuses it. The grammar's depth_number starts at 1. set_expand_depth(cat, 0) is guarded and removes the entry, but the field is public. No production path writes 0 today. Fix: make the map HashMap\u003cString, NonZeroUsize\u003e, as Expanded now is, or make the field private. Extend every_fold_the_api_can_build_reads_back_from_the_file_it_writes to cover it.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:31:54Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:31:54Z","labels":["core","io"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.11","title":"Autosave and the headless final write call persistence::save directly, bypassing App::storage (gotcha 2)","description":"Found reviewing PR #315 (ADR 006, head 50234b49). Citations are at gh/main b639266b.\n\nTwo writes go around `App::storage` (gotcha 2, improvise-fuj). Each calls the path-taking `persistence::save`, which always writes the real filesystem, whatever backend the host picked:\n\n1. **Autosave.** `App::autosave_if_needed` (`crates/improvise-command/src/ui/app.rs:1215-1224`, called from `src/draw.rs:93`) writes `persistence::autosave_path(path)` through `persistence::save(\u0026self.model_state.workbook, \u0026ap)`. It also runs on a wall-clock timer, with no effect and no command firing.\n2. **The headless final write.** `run_headless_commands` (`src/main.rs:442-446`) ends with `persistence::save(\u0026app.model_state.workbook, path)` rather than the `Save` effect through `app.storage`. `run_headless_import` (`src/main.rs:312`) does the same.\n\nConsequence: an `App` built with `App::with_storage(MemoryFs)` still writes to disk on autosave. So \"effects never touch std::fs\" holds only because these two are not effects. improvise-fuj's close reason says every document write goes through `App::storage`, and these two do not. Today both callers happen to use `PhysicalFs`, so nothing breaks yet. It is latent. It becomes live the moment a replay or test runs the TUI loop, or the headless loop, over `MemoryFs` (ADR 006 clause 6 proposes exactly that).\n\nFix direction: route both writes through `app.storage` (`persistence::save_to` or the `Save` effect). Demonstrate first with a test: an `App::with_storage(MemoryFs)` with `dirty` set and an elapsed autosave timer must write only into the `MemoryFs`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:21:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:21:47Z","dependencies":[{"issue_id":"improvise-31oe.11","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T05:21:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.9","title":"A typed 1.005 displays as 1.00 at two places: format_f64 rounds the binary value, not the typed decimal","description":"Reproduced at gh/main c91cc055 (format.rs, cell.rs and resolve.rs unchanged since b639266b) by a scratch crate outside the workspace: typed(\"1.005\") -\u003e CellValue::Number, then NumberFormat::parse(\",.2\").value(...) draws 1.00; format_f64(1.005, false, 2) likewise. Half-away-from-zero on the decimal the user typed gives 1.01. Same miss for 1.015 -\u003e 1.01 (want 1.02), 1.025 -\u003e 1.02 (want 1.03), 0.285 -\u003e 0.28 (want 0.29). 2.675, 0.125, 1.115, 8.345, 1.045 happen to agree.\n\nCause: format.rs round_half_away scales the f64 by 10^d. 1.005 is stored as 1.00499999999999989..., so 1.005*100 = 100.49999999999999, +0.5 truncates to 100.\n\nIndependent of ADR 005 (improvise-l2b8, PR #320), which fixes it as a side effect for exact values. A fix that needs no numeric-tower change: round the value's SHORTEST round-trip spelling (format!(\"{n}\"), the same spelling edit_text already uses, improvise-umi4) half-away-from-zero as a decimal string, so display rounds what was typed. Gotcha 8 holds either way: display-only, never fed back.\n\nTest first (AGENTS.md): format_f64(1.005, false, 2) == \"1.01\", plus 1.015/1.025/0.285, plus the existing pikk large-magnitude case.\n\nFound by the adversarial review of PR #320.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T05:19:07Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:19:07Z","dependencies":[{"issue_id":"improvise-felo.9","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T05:19:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.7","title":"Faces: theme download/upload, browser half","description":"ADR 004 Deferred: a Blob download of the current theme as JSON and a file input to upload one. Needs no configuration decision. The TUI half waits on improvise deciding how it is configured.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:39Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:39Z","dependencies":[{"issue_id":"improvise-nn4s.7","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:39Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.6","title":"Faces step 6: migrate the remaining TUI Color literals, including the twelve fg(Black).bg(accent) selection sites","description":"ADR 004 clause 8 step 6, opportunistic. The selection sites want a selection base face carrying reverse (clause 3/5), which is NOT a no-op on a TestBackend buffer (fg Black bg X vs fg X REVERSED) and is not named as a clause-8 exception -- needs the user's ruling before it lands. Do not touch src/ui/grid.rs while improvise-felo.6 is in flight.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:36Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:36Z","dependencies":[{"issue_id":"improvise-nn4s.6","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:36Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.5","title":"Faces step 5: browser maps faces to CSS custom properties through a 16-slot table","description":"ADR 004 clause 8 step 5 and clause 4's browser half. Ship a 16-slot table per scheme (light, optional dark; slot 2 = #2f6f4f / #8ec07c per the ADR), resolve Indexed 16-255 by the xterm formula in shared code, emit --face-\u003cname\u003e-fg/bg etc from the resolved theme, retire style.css's nine stray hex values, and pin by test that every face has a browser mapping. Also closes the AxisColor half of improvise-pcje.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:33Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:33Z","dependencies":[{"issue_id":"improvise-nn4s.5","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:33Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.4","title":"Faces step 4: retire MinibufColor from the wire; both front ends derive the minibuffer face from the mode","description":"ADR 004 clause 8 step 4. MinibufferConfig.color names colours, not roles. After step 2 the TUI no longer reads it; the browser still does (render.rs, app.js className). Remove the field and have the browser name minibuffer.\u003ctext-entry\u003e faces instead. Wire change: MinibufferConfig is in the Snapshot.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:31Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:31Z","dependencies":[{"issue_id":"improvise-nn4s.4","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:31Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nn4s.3","title":"Faces step 3: lift PanelContent::active_color into panel.{formula,category,view} faces","description":"ADR 004 clause 8 step 3. The three panels' accents are a private TUI table (Yellow/Cyan/Blue); lift them into faces derived from Panel::ALL so the browser can reach them.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T04:48:28Z","created_by":"agent-faces","updated_at":"2026-09-25T04:48:28Z","dependencies":[{"issue_id":"improvise-nn4s.3","depends_on_id":"improvise-nn4s","type":"parent-child","created_at":"2026-09-25T04:48:28Z","created_by":"agent-faces","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.13","title":"Folder import: a file with no measure columns gets a per-file view paged onto another file hidden measure","description":"Found reviewing PR #314 at 4e68372c. source_view sets the _Measure page selection only when the file has a measure; a file with none (e.g. entity,tag) leaves it unset, so the view pages onto the model first measure, which that same view hides. Reproduced: merge a.csv \"entity,x\" and tags.csv \"entity,tag\"; grid_in(tags) shows page _Measure=x and a Record cell for measure x, editable, under File=tags. Same family as the header-only CSV P2 on PR #314 and improvise-yd2t. Decide what a measureless file view shows (perhaps records mode, or _Measure off the page) and test it.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T04:35:25Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:24Z","started_at":"2026-09-25T04:36:55Z","closed_at":"2026-09-25T05:02:24Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.13","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:35:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.12","title":"Folder import: four per-file-view rules have no test that fails when they break","description":"Found reviewing PR #314 at 4e68372c. Each fault below was injected alone and the io lib suite (286), headless_cli and folder_import_views all stayed green: (1) source_view: derived date parts of a file own column not counted as own (replace the date_extractions clause with false) -- no folder fixture has a date column with extraction; (2) source_views: drop the intra-batch dedupe `views.iter().any(|v| v.name == name)` -- files Default.csv and \"Default 2.csv\" would both be named \"Default 2\" and the insert would silently lose one view; (3) merge_csvs_from: clear columns before each file instead of unioning same-stem files (a/x.csv + b/x.csv); (4) scalar_name: drop the trim, so a whitespace-only value becomes an item \" \" (pre-existing ych rule, now load-bearing for the sparse-record rule). Each rule is stated in context/code/formula-and-io.md; add a test that fails under each injected fault.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-lpzu","owner":"el-github@elangley.org","created_at":"2026-09-25T04:35:24Z","created_by":"Edward Langley","updated_at":"2026-09-25T05:02:22Z","started_at":"2026-09-25T04:36:52Z","closed_at":"2026-09-25T05:02:22Z","close_reason":"Landed in PR #314; reviewed PASS at c43ee724","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.12","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:35:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.11","title":"JSON import: a blank string in a numeric column makes it a Label, though the build treats \"\" as absent","description":"Found reviewing PR #314 (improvise-lpzu / 8jk6.2). analyze_records (crates/improvise-io/src/import/analyzer.rs) filters only Value::Null out of `present`, so a JSON column [10.0, \"\"] is mixed and proposed as Label, while Plan::place / scalar_name treat \"\" (and whitespace-only) as no coordinate. CSV is unaffected because parse_csv_field maps \"\" to Null. Reproduced: ImportPipeline::new(json!([{\"Region\":\"East\",\"Amount\":10.0},{\"Region\":\"West\",\"Amount\":\"\"}])) proposes Amount: Label. Decide: the analyzer should use the same blank rule as scalar_name (one home for \"is this value absent\"), and test it with a JSON numeric column holding \"\" and \" \". PR #314 context/code/formula-and-io.md claims the analyzer already classifies over present values only; true for null, not for \"\".","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:34:55Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:34:55Z","labels":["import"],"dependencies":[{"issue_id":"improvise-8jk6.11","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:34:55Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.10","title":"Show how many records the current view covers (and how many it cannot place)","description":"USER 2026-09-25, on whether a record missing a category that sits on Row/Column should appear under a '(none)' position: 'I think leaving it out for now is fine, maybe a UI element somewhere that shows the number of records underneath the current view?' Since sparse import (improvise-8jk6.2, PR #314) a record lacking a coordinate for a category on Row/Column appears in no grid position, silently. Add an indicator (status line / view bar in the TUI, the equivalent in the browser) showing the number of records under the current view, and ideally how many of those the grid cannot place because they lack a coordinate on a visible axis. The count is computed in the shared layer and projected (design-principles section 5), not derived per front end. Mind cost: it must not be an O(cells) scan per keypress (ADR 008/009: occupancy counts may provide it).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:53Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:30:53Z","labels":["browser","protocol","tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.10","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T04:30:53Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.7","title":"No headless output but a file: export to stdout","description":"The only headless output is a file. `export` writes a path, and there is no way to write a view to stdout, so every pipeline has to go through a temporary file and then `cat` it. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise cmd 'export -' -f m.improv\n\nWhat happens: a file named `-` is written, or the argument is taken as a path.\n\nWhat should happen: `export -`, or an equivalent flag on `improvise cmd`/`script`, writes the CSV to stdout, and status messages stay on stderr so the stream is clean. Needs deciding: whether this is a path convention in the command or a CLI-level option, given that effects never touch std::fs (gotcha 2) and the output goes through Storage. Related: improvise-2mqz (ExportCsv has no wire form).\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:07Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:07Z","labels":["area:cli","headless"],"dependencies":[{"issue_id":"improvise-31oe.7","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:30:07Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.10","title":"import --formula can only target _Measure: accept the [Category] target suffix","description":"`import --formula` can only target `_Measure`. The `Name = expr [Category]` suffix that `.improv` and the formula minibuffer accept is not available from the import flag, so a formula that computes an item of another category, such as a `Total` item of Region, cannot be added during a headless import. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise import s.csv --no-wizard --category Region --measure Sales --formula 'Total = North + South [Region]' -o s.improv\n\nWhat happens: the formula is rejected or lands in `_Measure`, rather than being a formula targeting Region. If it is dropped, see also the silent-drop ticket filed in the same batch.\n\nWhat should happen: `--formula` accepts the same target-category suffix as the other two entry points. This should go through the one formula parser rather than a new split. See improvise-zgb for the bug in the earlier split.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:06Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:06Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.10","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:06Z","created_by":"agent-gapfile","metadata":"{}"},{"issue_id":"improvise-8jk6.10","depends_on_id":"improvise-8jk6.5","type":"related","created_at":"2026-09-25T04:30:28Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.9","title":"Date --extract has no ISO week component","description":"Date `--extract` offers Year, Month and Quarter (import/analyzer.rs::DateComponent) but not the ISO week. Weekly reports, such as churn per week, cannot be built on import. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro:\n\n improvise import d.csv --no-wizard --time Date --extract Date:Week -o d.improv\n\nWhat happens: there is no Week component, so there is no weekly category.\n\nWhat should happen: a Week component whose item is the ISO 8601 year-week, for example `2026-W39`. It must use the ISO week-year, not the calendar year, so that late-December and early-January dates land in the right week. It should sort correctly as text, and the wizard should offer it. Related: improvise-8jk6.3 (a date column as one hierarchical category).\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:04Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:04Z","labels":["headless","import"],"dependencies":[{"issue_id":"improvise-8jk6.9","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:04Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.6","title":"Views and export have no sorting and no top-N","description":"Views and export have no sorting and no top-N. Rows appear in item insertion order (gotcha 18), and nothing can order them by a measure or keep only the largest N. Headless reporting, such as \"top 10 files by churn\", needs both. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1), which had to sort and cut outside improvise.\n\nWhat should happen: a view setting, available from the command language and persisted in `.improv`, that sorts the row axis by a column's value in either direction, with an optional limit. Export honours it. Needs deciding: where the setting lives (on the View, as number_format and prune_empty do); how it interacts with groups and multi-level rows; and whether a limit is part of the view or only an export option.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:03Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:03Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.6","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:30:03Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.8","title":"export writes formatted display text (\"17,512.00\") instead of values: offer raw values","description":"`export` writes each cell's formatted display text, for example `\"17,512.00\"`, instead of its value. The view's number format, with its thousands separator and fixed decimals, ends up in the CSV. Another tool then has to un-format the number, and the precision beyond the display rounding is lost (gotcha 8: display rounding is view-only). Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on a view with format `,.2f` and a cell holding 17512:\n\n improvise cmd 'export v.csv' -f m.improv\n cat v.csv\n\nWhat happens: the cell is written as `\"17,512.00\"`.\n\nWhat should happen: offer raw values, meaning full-precision numbers with no grouping and text as-is. This could be an option on `export` or the default for CSV. Which one is the default is a decision for the fix. Keep formatted output available for people who want what the screen shows.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:02Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:02Z","labels":["export","headless"],"dependencies":[{"issue_id":"improvise-8jk6.8","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:02Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8jk6.7","title":"export of a records view writes the editor's +cat and + columns into the CSV","description":"`export` of a records view writes the editor's creating columns, `+cat` and `+`, into the CSV. These columns are TUI affordances for adding a category or a measure (gotcha 1; improvise-2zq and improvise-c5v), not data. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on any model with data:\n\n improvise cmd 'toggle-records-mode' 'export r.csv' -f m.improv\n head -1 r.csv\n\nWhat happens: the header row ends with the `+cat` column name and a `+` column, and every row has empty cells for them.\n\nWhat should happen: export writes only the category and measure columns. The creating columns are session UI and belong to no document or file. See persistence/export.rs::render_csv and GridLayout::record_col.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:30:01Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:30:01Z","labels":["export","headless"],"dependencies":[{"issue_id":"improvise-8jk6.7","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T04:30:01Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-31oe.5","title":"Records-mode columns follow the view: hidden measures drop out, and a view can hide category columns","description":"A view cannot hide a whole category's column. `hide-item` hides one item at a time, and the only way to drop a category from the grid is to move it to an axis, which changes what is aggregated. Headless users who configure a view for export cannot make a column disappear while keeping the data it aggregates. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nExample: a records view over Region, Product and Month, where the export should omit the Month column but still include every record.\n\nWhat should happen: the view gets a per-category \"hidden\" setting, available from the command language and persisted in `.improv`, which removes that category's column from the grid and export. How it relates to the None axis in pivot mode, and whether it applies only to records mode, needs deciding first.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:59Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:57:23Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.5","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:59Z","created_by":"agent-gapfile","metadata":"{}"}],"comments":[{"id":"e8472f46-aa5a-5af8-a94f-558959990e04","issue_id":"improvise-31oe.5","author":"Edward Langley","text":"USER 2026-09-25 (asked 'Is there any way to [choose] just the columns shown in drill?'; answered 'I think 1+2 make sense, reseting is fine'): widened scope -- records-mode columns follow the VIEW. GridLayout::records_columns (view/layout.rs:591) takes only the model, so every regular category and every _Measure item (even one the view hides) is a column. Make it take the view: measures hidden in the view drop out, and a view can hide category columns (persisted like hidden items). Drill-specific defaults are a separate ticket.","created_at":"2026-09-25T04:57:18Z"}],"dependency_count":0,"dependent_count":1,"comment_count":1} +{"_type":"issue","id":"improvise-31oe.4","title":"create-view puts every unmentioned category on Page with its first item selected, silently filtering the view","description":"`create-view` puts every category the command does not mention on the Page axis, with that category's first item selected. The new view is therefore silently filtered to one slice of every other category. Headless users see totals that are missing most of the data. Found by the repo-stats pipeline, PR #313 (improvise-1xfb.5.1).\n\nRepro, on a model with categories Region, Product and Month:\n\n improvise cmd 'create-view V' 'set-axis Region row' 'export v.csv' -f m.improv\n\nWhat happens: Product and Month sit on Page with their first items selected, so v.csv shows one product in one month.\n\nWhat should happen: a category the user never placed should not filter the view. Put it on the None axis, which aggregates over it, rather than on Page with an arbitrary selection. A Page slice should be something the user asked for. This changes what `create-view` means in the TUI too, so it needs a decision before the fix. Compare improvise-cj4u, a closed ticket about Label categories in new views.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T04:29:58Z","created_by":"agent-gapfile","updated_at":"2026-09-25T04:29:58Z","labels":["headless","view"],"dependencies":[{"issue_id":"improvise-31oe.4","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T04:29:58Z","created_by":"agent-gapfile","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.9","title":"Expand ~ in paths typed into the TUI (:w, :import, save/export prompts)","description":"USER 2026-09-25, verbatim: 'resolving ~ in pathname: ~/.... this probable [sic] only makes sense in the TUI at the moment.' A path typed into the TUI -- ':w ~/x.improv', ':import ~/data.csv', the SavePrompt and ExportPrompt minibuffers -- is used literally, so '~' is a directory named '~'. The shell expands ~ for CLI arguments already, so this is TUI-only; the browser has no home directory (OPFS), so expansion must NOT live in the shared command layer or the Storage trait as a general rule. Expand '~' and '~/' against the user's home at the native edge (PhysicalFs, or where the TUI turns a typed string into a path); decide whether '~user/' is in scope (recommend no). Tests first: each of :w, :import, save and export resolves '~/x' under a fake HOME; a path merely containing '~' elsewhere is untouched; the browser/worker path is unaffected.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-25T03:59:27Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:59:27Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.9","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T03:59:27Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.8","title":"Doc generator links any code-span path that exists on disk, so ignored build output becomes a dead GitHub link","description":"From the PR #312 review (issuecomment-5826385748): pathHref in scripts/build-doc-site.mjs turns a code span into a github.com/.../tree/main/\u003cpath\u003e link whenever the path exists on disk, tracked or not. In a local build site/ exists (now gitignored), so the doc-site.md spans `site/` and `site/docs` link to a 404; the Pages build lacks site/ and renders plain text, so the published site is correct. Fix: pathHref asks git ls-files (tracked paths) instead of the disk; test with an ignored-but-present path.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:52:32Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:52:32Z","labels":["docs","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.8","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:52:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yd2t","title":"Page cycling ([ and ]) steps onto hidden items and draws an empty grid","description":"page_cat_data (crates/improvise-command/src/command/cmd/navigation.rs) and the layout's PageAxis items come from Model::effective_item_names, which ignores View::hidden_items. So a view that hides a Page item still pages onto it, and the grid is empty there. Found by improvise-lpzu: each per-file view from a folder import puts _Measure on Page and hides other files' measures, so ] in a 'coupling' view reaches 'age-months'. The Default view had the same behaviour before, but nothing in it hid a Page item. Decide whether paging skips hidden items (both front ends: the browser resolver pages too, protocol resolve.rs) and test it with a hidden item BETWEEN two visible ones.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:50:15Z","created_by":"agent-lpzu","updated_at":"2026-09-25T03:50:15Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.7","title":"scripts/agent-git: refuse a blank actor, and test the amend claim its suite names","description":"Found reviewing PR #310 at 256057b7 (improvise-1xfb.3). Non-blocking.\n(1) The refusal is [ -z ], so BEADS_ACTOR=' ' commits as 'Claude ( )' with exit 0, and an actor containing \u003c or \u003e is silently stripped by git ('a\u003cb\u003e' becomes 'Claude (ab)'). The refusal message also says \"unset\" for the empty case.\n(2) scripts/agent-git-test.sh line 45 comments \"a plain amend keeps the agent as author\" but asserts nothing about amend: a wrapper that appends --reset-author to any --amend passes 11/11. Assert it or drop the claim.\n(3) The script's \"why\" comment points at agent-rules.md, which only points on to context/test-evidence/tooling.md. Point at the file that carries the argument.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T03:36:45Z","created_by":"review-310","updated_at":"2026-09-25T03:36:45Z","labels":["agents","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.7","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:36:45Z","created_by":"review-310","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.6","title":"kb-test.sh case 18 hardcodes the baseline's location/3 count, so no one can add a location fact","description":"Found by the bookkeeping agent on PR #311 (2026-09-25): scripts/kb-test.sh case 18 asserts '34 of the baseline's are missing', which assumes exactly 35 location/3 facts in kb.pl. Adding location(grid_width, ...) failed the kb suite, so the fact was dropped. Make the case derive the count from the fixture/baseline rather than a literal; show it failing on a +1 location fact first.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T03:22:11Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:22:11Z","labels":["kb","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.6","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T03:22:11Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b45z","title":"No test pins that a Label category does not consume a MAX_CATEGORIES slot: counting labels toward the cap passes the whole workspace","description":"Found by the adversarial review of PR #307 (improvise-felo.1) at 7b9e868b. Injected fault: in Model::add_category (crates/improvise-core/src/model/types.rs), count Label categories toward MAX_CATEGORIES -- `.filter(|c| c.kind.is_regular() || c.kind == CategoryKind::Label)`. The whole workspace stays green (2139 tests). The rule 'a Label sits outside MAX_CATEGORIES' (core.md, design-principles.md, improvise-dy3j) has no guard: a_document_at_the_category_limit_plus_a_label_reloads adds its label AFTER filling to the cap, so the label is written and re-read after every regular category and never shares the count. Pre-existing at gh/main; not introduced by felo.1. Fix shape: a core unit test that adds a label FIRST and then fills to MAX_CATEGORIES regular categories, asserting all are accepted; plus the round-trip twin with the label section written before the regular ones.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:39:16Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:39:16Z","labels":["adversarial-review","core","pr-307"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo.2","title":"DataStore load is O(k log k) per cell and memory O(cells x k): store build tripled from 12 to 24 categories","description":"Measured on an M4 Max by improvise-felo.1 (examples/category_width_bench.rs): store build 26-30 ms at 12 categories vs 80-83 ms at 24 (2,500 records); 54-57 vs 163-172 ms at 5,000. Not a blocker; worth profiling CellKey construction/sorting and the secondary index insert. Distinct from improvise-sk32 (records-mode quadratic in records). Found by the improvise-felo.1 coupling inventory (MAX_CATEGORIES 12 -\u003e 24, PR #307; context/code/core.md section 9). Reachable at 12 too; more likely at 24.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T02:30:29Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:30:29Z","labels":["core","perf"],"dependencies":[{"issue_id":"improvise-felo.2","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T02:30:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-79h5","title":"agent-rules rule 8: newer-main SHA wording and the amend-vs-squash asymmetry","description":"Found reviewing PR #305 at ac2efdc8. Two wording gaps in agent-rules rule 8 (\"Reconcile diverging history only by merge\"), neither blocking:\n\n1. \"To take a newer main, run `git merge \u003cSHA\u003e` with the SHA you pinned (rule 7).\" The SHA pinned at task start is by definition not newer main. Rule 7 says re-resolve on purpose before merging a newer main, so rule 8 should say: re-pin (`git rev-parse gh/main`), then merge that SHA.\n2. The rule forbids squashing unpushed WIPs (`reset --soft`, a `rebase -i` fixup), but it forbids amending only \"a commit you have pushed\". Amending an unpushed commit also replaces history, so the two limits disagree. Ask the user which one is meant: either forbid amend outright, or say why an unpushed amend is allowed when an unpushed squash is not.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:59:02Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:15:34Z","closed_at":"2026-09-25T02:15:34Z","close_reason":"Landed in PR #305 (merge 6427312d), reviewed PASS at 9b9b1efa","labels":["agents","tooling"],"comments":[{"id":"81f8ed00-3aa3-52ad-9e6d-65765e1d1dae","issue_id":"improvise-79h5","author":"Edward Langley","text":"USER, 2026-09-25, verbatim: 'I don't mind amending on a branch that hasn't been pushed, but don't do anything that requires a force push.' Settles the second half: the line is the push, not the amend.","created_at":"2026-09-25T02:06:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-at5k","title":"kb.pl doctrines still treat history rewrites as routine after the merge-only rule","description":"Found reviewing PR #305 at ac2efdc8. The user's 2026-09-25 rule (no squash or rebase; reconcile diverging history only by merge; \"the merge structure of git history is a causal record\") is documented by #305 in AGENTS.md and agent-rules rule 8, but two kb.pl doctrines still read as licensing rewrites:\n\n1. doctrine(a_review_names_the_sha_it_reviewed): \"the author does not rewrite history while a review is in flight\" implies rewriting is fine otherwise. Should say the author never rewrites history (agent-rules rule 8) and announces any push.\n2. doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch): the remedy \"after any reset or force-move of a branch holding knowledge, re-count the predicates\" presents reset/force-move as a normal resync step. Under the rule, local main resyncs by `git merge --ff-only gh/main`, and a reset that drops commits is forbidden.\n\nkb.pl is orchestrator-only and written by `scripts/kb replace`; this ticket is for the orchestrator.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:59:01Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:44:45Z","closed_at":"2026-09-25T03:44:45Z","close_reason":"Both doctrines rewritten via scripts/kb replace in PR #311 (merged a4feee44); reviewed PASS","labels":["agents","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4klp","title":"Duplicate-category name check against date-derived categories is untested","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. ImportPipeline::taken_category_names includes the date-extraction names (e.g. when_Year), which is what stops a user naming the occurrence category after one. If the name slipped through, wb.add_category would be idempotent on the existing category and each cell key would carry that category twice. Removing the date_extractions chain from taken_category_names fails no test in the workspace. Add a test that types a date-derived name on NameDuplicateCategory and is refused.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:39Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["adversarial-review","import","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9bit","title":"Import collision check counts records with disjoint measures as colliding","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. collisions_among keys on the coordinate tuple only, so {region: East, revenue: 10} and {region: East, cost: 5} are reported as a collision although no cell would be summed (Sum builds both cells and raises no summed warning). Consequences: the wizard asks 'Sum them into one cell' where nothing sums, and 'improvise import --no-wizard' now refuses by default a sparse-measure CSV that imported cleanly before. Decide whether a tuple collides when records share it, or only when two records reach one (tuple, measure) cell, and pin it. Also untested: the 'a record with no measure collides with nothing' rule. Injecting 'if false \u0026\u0026 measures.is_empty()' in collisions_among fails no test in the workspace.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T02:22:13Z","closed_at":"2026-09-25T02:22:13Z","close_reason":"Landed in PR #304 (merged); reviewed PASS at 22cbfa67","labels":["adversarial-review","import","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gtn7.4","title":"Audit TUI text that truncates content the user may need: formula panel, help rows, import-wizard Preview","description":"Per the user's rule (bd memory messages-wrap-not-truncate): truncation is acceptable only for labels whose full text is available elsewhere. Sites reported by the improvise-cgqt agent: src/ui/formula_panel.rs:54 (formula text), src/ui/help.rs:238 (help rows, see also improvise-j4i9), the import wizard's Preview summary and field/label columns. Grid labels and cells truncate by design (their full text is reachable). For each site: decide wrap, or confirm the full text is reachable elsewhere and say where. Related: improvise-n8vh (panels overflow instead).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T01:46:57Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:46:57Z","labels":["tui","ux"],"dependencies":[{"issue_id":"improvise-gtn7.4","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:46:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j4no","title":"TUI draw panics on a terminal 0 or 1 row tall (index outside of buffer)","description":"Found reviewing PR #303 (improvise-gtn7.1), pre-existing and identical at gh/main ae5cf408: rendering draw() into a TestBackend of height 0 (any width \u003e=1) or height 1 (width \u003e=3), with or without side panels, panics in ratatui-core buffer.rs:251 'index outside of buffer: the area is Rect { .. height: 0 } but index is (0, 0)'. A real terminal can be resized that small; the TUI loop would crash. Probe: loop w 0..=120, h in {0,1,3,5,24}, all 8 panel masks, press Right x12 and draw; 1904 failing combos at both commits, all at h\u003c=1. Fix: find the widget that writes without checking its Rect (likely a bar or the welcome/grid block) and guard, then pin with that sweep.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:40:30Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:40:30Z","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j7ij","title":"No test notices a side panel that is open but never drawn: App::side_panel_open can drop a flag with the suite green","description":"Found reviewing PR #303 (improvise-gtn7.1). Injected: App::side_panel_open returns category||view, omitting formula_panel_open. cargo test -p improvise -p improvise-command stays green. Effect: the formula panel is toggled open (FormulaPanel mode reachable) but draw_content skips the whole side column, so the panel is invisible; grid and keypress path agree on full width, so gtn7.1's test cannot see it. Same hole pre-dates the PR (draw_content computed the OR inline). Fix shape: a draw-level assertion that each open panel's title/border appears in the frame, one panel at a time AND with all three open, asserting the direction the launch default (category open) cannot supply.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:40:26Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:40:26Z","labels":["tests","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-felo","title":"Epic: Core model, layout and formula evaluation","description":"improvise-core and improvise-formula: Model and DataStore correctness (07i0, nwgk, vwc7, 98pd), GridLayout prune and marker logic and its docs (dw2d), formula evaluation performance (9iyv), and ADR 005's exact-decimal arithmetic (l2b8). Out of scope: stable ids (the ADR 007 epic), .improv reading and writing (the import/export epic), and records mode and drill (their own epic).","status":"open","priority":3,"issue_type":"epic","owner":"el-github@elangley.org","created_at":"2026-09-25T01:04:12Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:04:12Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hexb","title":"ADR 007 clause 12: the regression test commits UNEDITED text (a no-op commit passes it), and which side holds the captured mapping is unstated","description":"Found by the delta review of PR #210 (ADR 007, `4830fa30..fcb8f539`). None of this blocks. Clause 12 resolves the contradiction it was written for. These are details the implementer of clause 12 will hit.\n\n1. **The required regression test can pass without exercising the mapping.** It says \"Commit the unedited text\". A host or client that treats an unedited commit as a no-op (the text equals what was shown, so send nothing, or `raw == was`, so write nothing) passes both assertions without resolving any name. That is the \"a rival that fails the setup\" vacuity shape. The test should change something the user typed that is not a name, for example `Revenue - Cost` → `Revenue - Cost * 1` (Codex's original scenario: \"edit only the constant/operator\"). Then assert that the stored formula references the original item's id.\n\n2. **Which side holds the captured mapping, and what `ReplaceFormula.raw` carries on the wire, is unstated.** Clause 12 cites `OpenedCell` (client-side, in `improvise-protocol/src/client.rs`) as the model. But it also says a name not in the mapping \"is resolved against the document at commit\", which only the host can do. So either:\n - `raw` travels as id-form text for the captured names and display text for new ones (a mixed rendering, which needs a rule for telling them apart, even though ids are globally unique); or\n - the mapping travels with the command; or\n - the host keeps a per-session open-editor record.\n\n The TUI (host-local) and the browser (remote) must reach the same answer. This overlaps improvise-m56i item 6 (resolving typed names at the panel edge).\n\n3. **Clause 11 (Migration)** does not list clause 12's capture step. The Consequences bullet \"the host must resolve display names typed there back to ids\" now describes only the names that are new to the edit.\n\n4. **Minor factual drift in the revised Context paragraph.** \"The only reader is one test\" is wrong: `category.rs`'s `add_item_returns_sequential_ids` and `add_item_duplicate_returns_same_id`, and `types.rs:1385`, all read the ids. `Category::add_item` returns the id on every call, not only on the idempotent path. The claim that matters, that no production caller uses the value, holds: `parse.rs:389` discards it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","updated_at":"2026-09-25T00:21:24Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-hexb","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-hexb","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-25T00:21:24Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"7834165f-01be-5178-97e7-d15d6c21a9fc","issue_id":"improvise-hexb","author":"Edward Langley","text":"Second copy of item 1 (review of PR #302 at 95d4814b, Claude agent): prolog-sessions/kb.pl decision('improvise-sboz', a_formula_edit_maps_display_names_to_ids_when_the_editor_opens, ...) restates clause 12's required regression test verbatim, including 'commit unedited'. When this ticket amends the test (edit a non-name token, e.g. Revenue - Cost -\u003e Revenue - Cost * 1), that kb fact must be updated in the same change via scripts/kb replace, or the kb keeps the vacuous form after the ADR is fixed.","created_at":"2026-09-25T00:51:28Z"},{"id":"e17020bb-539a-562f-8101-9f0b977ec4fd","issue_id":"improvise-hexb","author":"agent-adr007","text":"agent-adr007 planning. Items 1, 3 and 4 go in the text fix improvise-cq1q.1. Item 2 is a USER QUESTION and blocks improvise-cq1q.9. Recommendation: the client holds the capture, as it holds OpenedCell, and resolves typed names against its own CategoryTreeCache, which clause 6 gives ids. The wire then carries only id-form text, and the host never resolves a display name. The TUI and the browser share the resolver in improvise-protocol.","created_at":"2026-09-25T04:51:34Z"},{"id":"2849c32c-59f4-5d6e-b1cb-ef08af4cc8c6","issue_id":"improvise-hexb","author":"Edward Langley","text":"USER DECISION 2026-09-25 (item 2): the CLIENT holds clause 12's display-\u003eid capture (like OpenedCell) and resolves names against its id-bearing projection; the wire carries only id-form text; the host never resolves a display name.","created_at":"2026-09-25T17:51:49Z"}],"dependency_count":0,"dependent_count":1,"comment_count":3} +{"_type":"issue","id":"improvise-8vs2","title":"ADR 007 states stale facts about main: fdk1's RenameRecordCoordinate has landed, CellId::Coordinate exists, wpxr (#281) is merged","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30). The ADR's text about the current code is stale against its own base, gh/main ad217cc1:\n\n1. **improvise-fdk1 has landed** (PR #221, ticket closed 2026-09-24). `ModelCommand::RenameRecordCoordinate { record: RecordHash, category: String, item: String }` exists in `improvise-protocol/src/reduce.rs`. Yet the ADR says:\n - its header, \"Unblocks: improvise-fdk1\";\n - Context, \"A records-mode coordinate rename has no wire form (improvise-fdk1)\";\n - \"What this means for improvise-fdk1\", which proposes the operation as new (`to: ItemRef`).\n\n The section should instead revise the landed variant's operands to `CategoryId` / `ItemRef`, as it does for km71's `FormulaId`.\n2. **`CellId::Coordinate { record, category }` already exists** (render.rs). \"`CellId::Coordinate` grows `{ record, category: CategoryId }`\" should read \"its `category` becomes a `CategoryId`\".\n3. **improvise-wpxr (PR #281) has merged**, and the ADR still calls it \"open\": \"If improvise-wpxr (PR #281, open) lands first\". `SetCell { view, cell, value }` and `ClearCell { view, cell }` are on main, so they are simply on the list.\n4. Minor: \"No production code reads either value\" (`Category.id` / `Item.id`) is slightly overstated. `Model::add_category` and `Category::add_item` return them on the idempotent path. No caller uses the value, which is the claim that matters.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:31Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-8vs2","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:53Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-8vs2","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"5a61964c-18a7-5653-95b4-eb53e4685795","issue_id":"improvise-8vs2","author":"agent-adr007","text":"agent-adr007 planning: revision 8 fixed items 1 to 3. Item 4, the 'idempotent path' wording, remains and is folded into improvise-cq1q.1.","created_at":"2026-09-25T04:51:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-p003","title":"ADR 007 namespace gaps: _Records/_Drill ids, _Index/_Dim items under global uniqueness, active_view, collapsed: by group name, CheckedBatch's create exemption","description":"Found by the adversarial review of PR #210 (ADR 007, head 4830fa30), against gh/main ad217cc1. These are places where the id namespace and the list of re-keyed state are incomplete:\n\n1. **Virtual views.** `view::VIRTUAL_VIEW_NAMES` is `[\"_Records\", \"_Drill\"]`. Answer 23 reserves `_` for virtual things, but clause 1 reserves only `_index`, `_dim` and `_measure`. Slugify's step 4 turns `_Drill` into `drill`, which collides with a user view named `Drill` or takes a gensym suffix. So a virtual view's id is neither reserved nor stable. They are never persisted, but they are addressed on the wire (`SwitchView`, and `_Drill` in improvise-obtu/t219).\n2. **`_Index` and `_Dim` items.** These are synthetic, such as record indices, and `_Dim` carries a column index (gotcha 1). Answer 22 makes ids unique across every kind. The ADR does not say whether these items take part. If they do, `_Index`'s `0` collides with a user item whose id is `0` (for example a display `0`).\n3. **`ViewState::active_view` and `Workbook::active_view`** are view names (`String`). Clause 6 lists cursors, expanded_cats and the view history as keyed by id, but not the active view itself.\n4. **`collapsed:` names a group.** `collapsed_line = \"collapsed: \" name \"/\" name` is category/group. Groups have no id (answer 9), but clause 2 says \"hidden and collapsed sets are keyed by ids\", and clause 5 lists `collapsed:` among references that carry the id. Only `[Coastal]` is exempted. So the collapsed set is (CategoryId, group name).\n5. **CheckedBatch's created-record exemption** (gotcha 43: a record that neither resolves nor is claimed, for `[AddItem(East), SetCell(East)]`). Clause 6 says a frame cannot address what it creates, so that exemption has no remaining producer. It should be removed or re-argued with the id vocabulary, not left as dead reasoning.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","updated_at":"2026-09-24T22:49:31Z","labels":["adversarial-review","pr-210"],"dependencies":[{"issue_id":"improvise-p003","depends_on_id":"improvise-cq1q","type":"parent-child","created_at":"2026-09-25T01:04:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-p003","depends_on_id":"improvise-sboz","type":"discovered-from","created_at":"2026-09-24T22:49:31Z","created_by":"agent-review-210","metadata":"{}"}],"comments":[{"id":"26a29b15-8906-5014-a8cb-1725955f0b89","issue_id":"improvise-p003","author":"agent-adr007","text":"agent-adr007 planning: all five items are readings for improvise-cq1q.1. Item 2 (_Index/_Dim coordinates are positions outside the id namespace) is the ADR's reading and should be flagged to the user on that PR. Item 5 is implemented in improvise-cq1q.8.","created_at":"2026-09-25T04:51:36Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-3d64","title":"renamed_away() says 'the row you were editing has changed' when only the edited cell's measure went (Opened::Gone on a value target)","description":"Codex on #274 (discussion_r4090469430), repeated on integration PR #299 at 8dd39fd5. Opened::Gone is also produced for a value target when the same record remains but the edited measure disappears or changes, yet resolve.rs renamed_away() / RECORD_RENAMED_AWAY says the ROW changed. Use target-neutral wording (the cell you were editing), and pin it with a test that reaches Gone through a measure change on a surviving record.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:35:31Z","created_by":"agent-train2","updated_at":"2026-09-24T19:35:31Z","labels":["browser","pr-274"],"dependencies":[{"issue_id":"improvise-3d64","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xebx","title":"checkers.sh accepts a repeated --ci, and the last selector silently wins","description":"Codex P2 on #298, originally on #228 (https://github.com/fiddlerwoaroof/improvise/pull/228#discussion_r4090576827). In scripts/checkers.sh the argument loop overwrites ci_job on each --ci. So --ci js --ci build exits 0 having run only the build row. The later check rejects extra NAMES, but not a second selector. CI calls it with exactly one --ci per job today, so nothing is misrouted now. It is a malformed invocation that looks like success. Fix: return 4 on a second --ci. Add the case to scripts/checkers-test.sh and inject the fault to watch it fail.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:36Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:36Z","labels":["adversarial-review","pr-228","pr-298","tooling"],"dependencies":[{"issue_id":"improvise-xebx","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7298","title":"settle_relay queues every relay behind the head frame, but a relay the host applied BEFORE that frame then replays in the wrong order","description":"Codex P2 on #298, originally on #266 (https://github.com/fiddlerwoaroof/improvise/pull/266#discussion_r4090546517). ClientState::settle_relay (crates/improvise-protocol/src/client.rs) inserts a relay directly behind in_flight's head (improvise-jpba). That is right when the host processed the relay after the head frame. But a relay that another client caused can reach the host BEFORE the head frame and still arrive at this client before the head's answer. Then the host's mirror has applied relay, then head, while this client replays head, then relay. If the head is accepted and its view commands do not commute with the relay (for example SetSelected in the head and ResetCursor or SwitchView as the relay), the client and its mirror diverge. The divergence is in view state only; document data is unaffected. It is often masked because the change that emits such a relay also moves the basis, so the head is refused, and on a refusal the ordering is right. The client cannot tell the two orders apart from the stream it receives, so the fix probably needs a marker from the host, such as the count of this session's frames it had processed when it sent the relay. Codex's wording (a later local view command) does not match the code: a later unawaited frame already stays behind the relay. The real case is ordering relative to the head frame itself. Regression: a relay the host applied before an accepted head that carries a non-commuting view command.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:34Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:34Z","labels":["adversarial-review","browser","pr-266","pr-298"],"dependencies":[{"issue_id":"improvise-7298","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-u7xp","title":"Wire RenameRecordCoordinate accepts a virtual category: _Measure rewrites the measure to a non-item, _Dim stores a virtual coordinate","description":"Copilot on PR #221 (inline at crates/improvise-command/src/ui/reduce.rs:160), confirmed by the adversarial reviewer at 37ff61f9. The code is the same from #221 up through #278.\n\n**Cause.** The host's wire `RenameRecordCoordinate` checks only that the source record resolves. `category` comes from the client and is passed on as `RecordCol::Category` without being validated. `RecordRewrite::of` then rewrites whatever coordinate it names.\n\n**Probe** (real Host, `three_records_host`, a frame built by `caught_up`):\n- `category: \"_Measure\", item: \"Bogus\"`: **Accepted**. North/Revenue becomes `{Region=North, _Measure=Bogus} = 1`, a measure coordinate that is not an item.\n- `category: \"_Dim\", item: \"Bogus\"`: **Accepted**. It stores `{Region=North, _Dim=Bogus, _Measure=Revenue} = 1`. `_Dim` is virtual and must never be stored (repo-map, `.improv` notes).\n- `category: \"Nope\"`: `Rejected(MutationRefused)`, and North is unchanged. So Copilot's \"unknown category is also unsafe\" does not hold at this head.\n\nOnly a hand-built frame reaches this. The shipped client takes `category` from a projected `CellId::Coordinate`, which names a regular category. It is the same class as improvise-nacd (wire well-formedness, not a security boundary: gotcha 43), so P3.\n\n**Fix direction:** refuse a rename whose category is not a regular category (`CategoryKind::Regular`) the record has as a coordinate.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T09:29:31Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T09:29:31Z","labels":["adversarial-review","pr-221"],"dependencies":[{"issue_id":"improvise-u7xp","depends_on_id":"improvise-nacd","type":"related","created_at":"2026-09-24T09:29:36Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-u7xp","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-q98f","title":"settle_declined_editor closes a captured, untouched editor when a remote reframe puts the cursor on an aggregate (3h9w's blind spot, in the settle)","description":"Copilot on PR #274 (inline at client.rs:304), confirmed by the adversarial reviewer at #278's head 37ff61f9. The #274 × #295 merge (bce496b7) is where the two meet.\n\n**Cause.** #295's `settle_declined_editor` (5m9z) closes ANY untouched editor whose cursor cell `declined_at` refuses. It does not look at `state.opened`. So an editor that has captured a still-valid stored cell (#274, improvise-8xq0) is closed when a relayed reframe leaves the cursor on an aggregate or derived cell. That is the same blind spot 3h9w fixed in `refused_here`.\n\n**Probe** (real Host, `host()` plus `Profit = Revenue - Cost`):\n1. A presses `l i`. The editor is open on North/Cost, the capture is taken, and nothing is typed.\n2. B sets Region's axis to None. The relays are delivered, and the reset puts A's cursor on (0,0), an Aggregate.\n3. A's mode becomes **Normal**, the capture is dropped, and the status reads \"Cannot write to an aggregate…\", although A tried to write nothing.\n\nThe touched variant is fine: it stays open and Enter writes North/Cost = 42, which is 3h9w's test. The TUI keeps its editor open in the same situation, so the two ends disagree.\n\nNo wrong write is possible (the user typed nothing), so P3.\n\n**Fix direction:** skip the close when `self.opened.is_some()`, the way `refused_here`'s 3h9w arm does. Keep it for an uncaptured editor, which is the case 5m9z (an advance past the window) was written for.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T09:28:46Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T09:28:46Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-q98f","depends_on_id":"improvise-3h9w","type":"related","created_at":"2026-09-24T09:28:52Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-q98f","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-jni7","title":"Release capture still pins wasm-pack/node via web-toolchain-versions.env; since #297 nothing links those pins to the .#ci shell CI tests with","description":"## Summary\nFound reviewing PR #297 at head c29e32f1. This is follow-up hygiene, not a blocker.\n\n#297 removed the ci.yml step \"The dev shell supplies the pinned web toolchain\". That step held the dev shell's `wasm-pack` and node major equal to `scripts/web-toolchain-versions.env`. The user's ruling is that CI does not police versions, and CI now uses whatever `.#ci` provides.\n\nBut `scripts/build-web-release.sh` still sources `web-toolchain-versions.env`, and on Linux it installs **that** `wasm-pack` and node for the release artifact (release.yml). So:\n- the release's `improvise-web.tar.gz` can now be built with a different `wasm-pack`/node than any CI run exercised;\n- nothing links the pins file to `flake.nix` any more.\n\nThe file's own header says \"two copies of a pin is one of them going stale\". It is now exactly that.\n\n## Options\n- Point the release capture at the nix toolchain too (`nix build .#web`, as CI's `build` job does), and delete `web-toolchain-versions.env`.\n- Or state in `context/code/tooling.md` that release versions are deliberately independent of CI's.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T08:55:46Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T08:55:46Z","labels":["adversarial-review","pr-297","release"],"dependencies":[{"issue_id":"improvise-jni7","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:33Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0acd","title":"AxisFrame::body_from adds a wire-supplied first without checking: first = usize::MAX panics in debug, wraps in release","description":"Found reviewing PR #250 at cfa8bf1e. body_from computes self.first + idx with first taken straight off the wire. A malformed or hostile frame with first near usize::MAX overflows. Pre-existing. Fix alongside #250's try_from validation: refuse a frame whose first + entries overflows, or use checked arithmetic.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T06:54:10Z","created_by":"orchestrator","updated_at":"2026-09-24T06:54:10Z","dependencies":[{"issue_id":"improvise-0acd","depends_on_id":"improvise-tr08","type":"parent-child","created_at":"2026-09-25T01:04:50Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rv02","title":"checkers.sh check_shebangs passes vacuously when head is absent","description":"Found while fixing CI (PR #296). check_shebangs runs first=$(head -1 \"$f\") inside 'check_shebangs || rc=1' where set -e is off; with head missing every first line is empty and the shebang check passes checking nothing. A utilbin rival without head fails no checkers-test case. Fix: fail loudly if head (or the read) fails; add a case.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T06:15:22Z","created_by":"orchestrator","updated_at":"2026-09-24T06:15:22Z","dependencies":[{"issue_id":"improvise-rv02","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-txh4","title":"Type-ahead across a refused commit: text typed into the reopened editor replays into the refused cell's editor, and the retry writes it there","description":"Found reviewing PR #274 at head 8d9de94e. Not a regression: at gh/main the retry writes the cursor cell, which is the same cell here.\n\n**Steps (real Host, the `host()` fixture):**\n1. A presses `l i 4 2`, so the editor is open on North/Cost with `42` typed.\n2. B writes North/Revenue. B's relays are held.\n3. A presses Enter. The commit is sent, the advance reopens the editor on South/Cost (1,1), and the buffer is empty.\n4. **Before the answer arrives,** A types `7` into the South/Cost editor. That sends the unawaited frame `[SetBuffer edit \"7\", SetOpenedEditor touched]`, which queues behind the awaited commit.\n5. The commit is refused with `RecordMoved(north)`.\n6. `refused()` drops the commit frame and replays the settled slice plus the rest of `in_flight`. The editor is back on North/Cost (0,1), but the buffer is **\"7\"**: the SetBuffer composed for the South/Cost editor lands in the restored North/Cost editor.\n7. `opened` is `Retry(north, Cost)`. The recapture matches, so it becomes `Target`.\n8. The retry Enter is `Accepted`.\n\n**Result:** North/Cost = 7. The user's 42 is gone, and 7 (typed for South/Cost) is stored in North/Cost.\n\nThe `Retry`/`Gone` rule (9ww8, 8phw) checks that the **cell** is the refused frame's cell. It does not check that the **text** belongs to that editor. The replay of type-ahead is the #221-era `refused()` design (settled + remaining in_flight).\n\n**Options, none tried:**\n- (a) On a refused commit, drop the in-flight editor frames composed after it, since they belong to an editor the refusal unmade.\n- (b) Restore the refused frame's own typed text as the buffer.\n- (c) Decline the retry when the buffer was touched by a later editor.\n\nNeeds a keypress inside one host round trip plus a crossing write, so it is P3. Related to improvise-n4i9 (type-ahead into an awaiting editor) and improvise-8phw.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:39:48Z","created_by":"agent-review-221-251","updated_at":"2026-09-24T01:39:48Z","labels":["adversarial-review","pr-274"],"dependencies":[{"issue_id":"improvise-txh4","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-txh4","depends_on_id":"improvise-8phw","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-txh4","depends_on_id":"improvise-n4i9","type":"related","created_at":"2026-09-24T01:39:55Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cz3o","title":"EnterEditAtCursor's status-not-refuse rule is unguarded, and a scripted commit whose advance lands on a declined cell prints a refusal to stderr (#277)","description":"Found in adversarial review of PR #277 (improvise-enz6), head aab62a36.\n\n1. **The status-not-refuse rule is unguarded.** `context/code/command.md` (#277) says `EnterEditAtCursor` writes a status line and not `App::refuse`, because the commit before it succeeded: \"A `commit-cell-edit` script line whose advance lands on a fold must not report its write as refused.\" Rival: `Some(refusal) =\u003e app.refuse(refusal)` in `EnterEditAtCursor::apply`. The whole workspace stays green (`cargo test --workspace --no-fail-fast`). Measured through the CLI, the rival breaks what the rule protects. With `_Measure` on rows [Revenue, Profit=formula] and Month on columns, `improvise cmd 'commit-cell-edit editing 7 Month/Jan _Measure/Revenue' -f t.improv` stores 7 and exits **1** under the rival. It exits 0 under the PR.\n\n2. **A successful scripted commit now prints a refusal-shaped line.** On the same fixture under the PR, the command exits 0 and stores 7, but stderr reads \"Profit is computed by a formula — edit the formula, not the cell\". At 202e7eac (gh/main) the same command prints nothing. The user asked to write Revenue, and the message is about a cell they did not touch. It comes from the re-entry the headless run does not need.\n\n3. **A false comment.** `resolve::AGGREGATE_DECLINE` is documented as \"What the host says of an aggregate, the fold of a collapsed group included\". The host says `FOLD_REFUSAL` (\"Collapsed group — press z to expand it first\") of a fold, and `context/code/clients.md` (#277) says so itself.\n\nAC: a test fails under the rival in (1). It can assert `!last_batch_refused()` after a commit whose advance lands on a declined cell, or the CLI exit code. Decide whether (2) is acceptable and say so in `context/code/cli.md`, or suppress the re-entry's status for a headless run. Correct the comment in (3).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:27Z","created_by":"agent-review-277","updated_at":"2026-09-24T01:29:27Z","labels":["adversarial-review","area:cli","pr-277"],"dependencies":[{"issue_id":"improvise-cz3o","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ik6l","title":"Browser Enter on a touched editor over a declined cell is a silent Upstream: no write, no message, editor stays open (#282's kept editor)","description":"Found in adversarial review of PR #282 (improvise-5m9z), head 942f57e5. It answers the orchestrator's question \"a touched editor survives; what happens at commit?\"\n\n#282 keeps a TOUCHED editor open on a cell the host later projects as declined (a fold, an aggregate or a formula's result). The case is an advance past the window followed by typing before the reply arrives. Enter on that editor goes through `resolve::commit`. Its `id` is Aggregate or Derived, so `written` -\u003e `record_at_cursor` -\u003e `None`, and `commit` returns `None`. The key outcome is `KeyOutcome::Upstream`. `Host::handle` answers a Key frame with nothing (gotcha 47), so Enter does nothing: no write, no message, and the editor stays open with the user's text.\n\nRepro (reviewer's scratch test on 942f57e5, not pushed): `fold_host`, window (2, 6), cursor at (1,1). `i`, `Enter`, `7` are folded and sent before any reply is delivered, and then the replies are delivered. Then `Enter`: `resolve` returns Upstream. The mode is still Editing, the status is \"\", the buffer is \"7\", and the host's cell count is unchanged.\n\nThe TUI never reaches this state, because it declines the open. Its nearest equivalent is an editor already open on a fold (`an_editor_already_open_on_a_fold_commits_nothing_there`), where Enter says \"Collapsed group — press z to expand it first\". The browser should decline out loud the same way, with `declined_at`'s text. This is the same family as improvise-l3hm and improvise-kkcy (silent Upstream where the TUI declines).\n\nThe #282 docs say \"no write can follow on that cell\". That is true, but it does not say that Enter goes dead.\n\nAC: Enter on a touched editor over a declined cell composes a SetStatus decline, not Upstream. A test drives this state, and a rival that returns `None` fails it.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:29:13Z","created_by":"agent-review-277","updated_at":"2026-09-24T21:46:02Z","closed_at":"2026-09-24T21:46:02Z","close_reason":"Landed in main via #282 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-282"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kimw","title":"An empty-record write naming a non-host view re-derives that view's layout (and, with formulas, clones + recomputes the model) ~5x per command","description":"Found by the adversarial review of PR #281 (improvise-wpxr) at head ba3d2875.\n\nA wire SetCell/ClearCell of an EMPTY record, naming a view the host is not on, derives that view's layout once per resolution call, not once per command. The calls are: CheckedBatch's record tier (will_be_created_by_the_frame, stale_record), broken_chain, and model_effects (unwritable -\u003e resolve_cell and aggregate_refusal, then resolve_cell again). That is five or six derivations per write, before grid_in derives the same layout once more to project it.\n\nWithout formulas each derivation is only GridLayout::with_frozen_records, which is cheap (about 2.4ms measured). But App::with_layout_of goes through project::rederived_for. When the two views' none_cats differ and the document has any formula, that CLONES THE MODEL AND RUNS recompute_formulas on every call.\n\nMEASURED (release build, Apple Silicon laptop under load, so noisy; a scratch test, discarded). Document: Region(150) x Product(120) x _Measure{Revenue, Cost}, about 18k stored Revenue cells, plus one formula (Profit = Revenue - Cost). The host sits on \"Other\", which puts Product on None. The client names \"Default\".\n- ClearCell of an empty record: best of 5 = 1.82s.\n- SetCell of a stored record, same host: 0.65s.\n- One resolve_record of the empty record alone: 227ms.\n- So about 1.1s per write is repeated derivation.\n- For contrast, with no formula the empty-record write costs the same as the host-on-Default case (about 0.24-0.34s, dominated by the pre-existing projection cost, which is improvise-swre).\n\nWho pays: the browser worker deployment most of all. There, the App's view_state is the host's own, so every client on a non-default view is \"not on the host's view\". Holding clear-cell or paste over a column of empty cells pays about 1s+ per key on a mid-size document with a formula.\n\nFix shape: derive once per command, or once per frame. For example, resolve the named view's layout once in model_effects and in the CheckedBatch record tier and pass it down. Or memoise the derived (model, layout) keyed on (view name, layout_version, document_generation), together with improvise-swre. Correctness is not in question: #281's resolution is right. This is cost only.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:23:47Z","created_by":"agent-review-281","updated_at":"2026-09-24T21:45:56Z","closed_at":"2026-09-24T21:45:56Z","close_reason":"Landed in main via #281 (reviewed; merged 2026-09-24).","labels":["adversarial-review","performance","pr-281"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s2gb","title":"every_silent_binding_is_on_the_list walks only Normal and search: a silent binding in a panel mode passes the whole suite","description":"Found reviewing PR #280 (improvise-oyaa) at 88e53aed. The new guard walks only ModeKey::Normal and SearchMode, but the browser also stands in the formula, category and view panels, TileSelect, Help, Editing and RecordsNormal. Rival: I added .bind(KeyCode::Char('Z'), \"commit-export\") to the FormulaPanel keymap. The client has no arm for commit-export, so Z goes Upstream, a black hole. cargo test --workspace stayed at 0 failed. The #264 help-row guard cannot see it because no help row names it, and the #280 guard cannot see it because it never walks FormulaPanel. A throwaway probe ran the PR's own every_binding and press over every AppMode's keymap, with the search query both empty and seeded. At head it finds no silent, unlisted binding in any mode the browser can enter. The only hits are modes whose door the browser refuses (COMMAND, EXPORT, SAVE AS, IMPORT) and RECORDS INSERT, whose entry edit-or-drill declines. So widening the guard to every mode except those costs nothing today and closes the gap. The exclusions should be stated as the modes declined() refuses at the door, not as a hand list.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:21:55Z","created_by":"agent-review-264","updated_at":"2026-09-24T21:46:01Z","closed_at":"2026-09-24T21:46:01Z","close_reason":"Landed in main via #280 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i5z6","title":"improvise-formula lib test binary measured over the 2s tripwire under load (2.2-5.5s)","description":"Reported by two agents on 2026-09-23 (M4 Max, heavy parallel load): improvise-formula's lib test binary took 2.21s, 3.84s and 5.47s; earlier 1.77s. May be load, but design-principles §7 says compare to the run's median binary. Measure unloaded and as a ratio to the median; if it is an outlier, find the cost (likely proptest/grammar work, cf. improvise-8shu's OnceLock fix for improvise-io).","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:38Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:53Z","closed_at":"2026-09-24T21:45:53Z","close_reason":"Landed in main via #284 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3j70","title":"The GIT_CONFIG_GLOBAL/NOSYSTEM nulls in run_git have no witness: vwjj's -c core.excludesFile=/dev/null masks the excludes-based one","description":"Found while doing improvise-eahe, and true at #276's head before eahe as well.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1` (improvise-53zz). The witness for both halves is a global or system config whose `core.excludesFile` refuses `*.gz`. Since improvise-vwjj, `run_git` also passes `-c core.excludesFile=/dev/null`, and a `-c` argument outranks every config file. So the witness can no longer see either half.\n\nMeasured by injecting each rival into `tests/shipped_documents_load.rs` and running the binary. Both rivals are **green**, both on the per-setting tests at ddcdf93a and on eahe's combined test:\n- dropping `GIT_CONFIG_GLOBAL=/dev/null`;\n- dropping `GIT_CONFIG_NOSYSTEM=1`.\n\nThe two lines are still worth keeping, because they null every *other* config key a user might set. But no test pins them. The fix is a witness that uses a config key which no `-c` argument overrides and which breaks `git init` or `git add`. For example, `core.sharedRepository = nonsense` might fail `git init`, but that is untested; check it on git 2.55 first. Put it in the same hostile child as eahe's other settings, and show both rivals red.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:18:29Z","created_by":"agent-docs-wave","updated_at":"2026-09-24T21:45:10Z","started_at":"2026-09-24T01:24:58Z","closed_at":"2026-09-24T21:45:10Z","close_reason":"Landed in main via #285 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5m9z","title":"Browser advance past the announced window opens the editor on an unprojected cell","description":"Found on PR #277 (improvise-enz6). resolve::reopened_at asks the landing cell's projected CellId to decline folds/formulas; an advance past the client's announced viewport lands on a cell with no projected id yet, so the browser opens the editor there regardless. No write can follow (the commit needs a projected record), but the editor state diverges from the TUI, which declines. Fix: treat an unprojected landing as await-the-host (like opened_on's awaiting seed) rather than open.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T01:03:25Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:54Z","closed_at":"2026-09-24T21:45:54Z","close_reason":"Landed in main via #282/#295 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-oyaa","title":"NEEDS_THE_HOST completeness is checked only where a help row names the key","description":"Found on PR #275 (improvise-fo0w). resolve::NEEDS_THE_HOST's two guards (#264) prove no false entries and that no help row is an unmarked dead key, but not that every dead binding is listed: removing force-quit from the list fails no test, because no help row names Ctrl-Q. Fix: derive completeness from the Normal and search-mode keymaps (every binding either resolves locally, declines out loud, or is listed), not only from help rows.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:02:24Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:18Z","started_at":"2026-09-24T01:02:41Z","closed_at":"2026-09-24T21:45:18Z","close_reason":"Landed in main via #280 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0629","title":"a_help_row_the_browser_cannot_run_is_drawn_marked does not see an ex row drawn plain","description":"Found reviewing PR #264 (improvise-uln5) at cf5c6fc7. The render test asserts the search row's kind is dim, but for the ex-command (Literal) rows it asserts only that the description contains 'not in the browser'. Rival: in RenderHelpRow's Literal arm, override kind back to 'dim' only when the macro's dim flag is set (ignoring runs_the_command_line). The non-dim ex rows (:w, :q, :add-cat ...) then render as help-keys with a marked description, and all 21 render tests pass. The production code is correct today (verified in Chromium: every ex row is help-dim); this is a missing assertion. Fix: assert row.kind == dim for every ex row too.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:40:35Z","created_by":"agent-review-264","updated_at":"2026-09-24T21:45:59Z","closed_at":"2026-09-24T21:45:59Z","close_reason":"Landed in main via #275 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-joed","title":"A commit declined as 'still waiting' loses the typed text: Enter's welded clear-buffer still runs","description":"Found by agent-8xq0 (PR stacked on #221). In the browser, a keyboard commit whose target is not captured yet (the host has not answered this client's own last write) returns the status 'still waiting for the host to answer your last edit' and writes nothing. Since improvise-8xq0 this applies to value cells as well as coordinate renames. But Enter in Editing/RecordsEditing is the Sequence [commit-cell-edit, clear-buffer edit], and the sequence resolver keeps the clear-buffer step, so the editor stays open with an EMPTY buffer: the text the user typed is lost. Pinned as observed in resolve::tests::a_value_commit_before_the_host_answers_the_last_one_waits_out_loud (which asserts only the status and no write). improvise-fy9o (PR #227) moves the clear to the head of the commit command; whether the waiting decline then keeps the text depends on where that clear lands relative to the decline, so check after both merge. Reachable only by pressing Enter twice inside one host round trip.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-8xq0","owner":"el-github@elangley.org","created_at":"2026-09-24T00:36:48Z","created_by":"agent-8xq0","updated_at":"2026-09-24T21:45:13Z","started_at":"2026-09-24T00:51:06Z","closed_at":"2026-09-24T21:45:13Z","close_reason":"Landed in main via #278 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3til","title":"App::restore's rebuild-after-view_state order is unguarded: rebuilding before view_state is restored passes the suite","description":"Found by the adversarial review of PR #261 (improvise-8wy9) at head 86b3d72c.\n\nApp::restore rebuilds the layout LAST, after view_state is restored. rebuild_layout reads view_name(), which comes from view_state.active_view, so the order matters. Nothing guards it.\n\nRIVAL THAT PASSES: move `self.rebuild_layout();` up to just after `self.model_state.workbook = snapshot.workbook;`, so it runs before `self.view_state = snapshot.view_state`. cargo test -p improvise-command --no-fail-fast stays green (648 passed), and so does #261's new test. Its frame never moves the host's view, so both orders give the same layout.\n\nWHY IT MATTERS: a client's DeleteView of the view the host is on moves the host's view_state.active_view (effect::DeleteView, `app.view_state.active_view = app.view_name()`). If that frame is then refused and rolled back, the rival builds the layout for the default view, then puts the host back on the deleted-and-restored view. The host's layout then describes a different view from the one it is on, and the next frame resolves its surrogates against that wrong grid.\n\nWITNESS (a scratch test, run and then discarded): host() plus a view \"Other\" (Region on Column, _Measure on Row, so 1 row x 2 cols), with the host switched to Other and the layout rebuilt. A client sends the frame [DeleteView(Other), RemoveItem(South), SetCell(South)]. It is refused ChainBroken. The test asserts view_name()==\"Other\" and layout.row_count()==1 and col_count()==2. It passes at 86b3d72c. Under the rival it fails at row_count, 2 against 1.\n\nCHEAP FIX: add that test next to a_frame_after_a_rolled_back_reshape_resolves_against_the_restored_grid in ui/host.rs. Better still, follow it with a write that resolves only through Other's layout.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-24T00:21:33Z","created_by":"agent-review-261","updated_at":"2026-09-24T21:44:53Z","started_at":"2026-09-24T00:22:36Z","closed_at":"2026-09-24T21:44:53Z","close_reason":"Landed in main via #267 (reviewed; merged 2026-09-24).","labels":["adr-001","adversarial-review","pr-261","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qnry","title":"Tab-commit's rightmost-column clamp in the browser resolver is unasserted: dropping .min(col_max) passes the whole workspace","description":"Found in the adversarial review of PR #256 (improvise-rq3f) at head eb146bcc. This predates #256, which is test-only.\n\nIn crates/improvise-protocol/src/resolve.rs, `advanced` has the arm `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))`. Dropping the clamp gives `Some((row, col + 1))` (rival `right_unclamped`). That passes the whole workspace: cargo test --workspace --no-fail-fast, 1942 passed, 0 failed.\n\n#256's new test `tab_in_an_editor_commits_and_lands_one_column_right` sits on a centred cursor on purpose, so no edge clamps the move. That makes it blind to the clamp. The host's `CommitAndAdvance` (`AdvanceDir::Right`, commit.rs:1020) clamps with `(col + 1).min(col_max)`. Without the clamp the browser, pressing Tab at the rightmost column of a non-records grid, would send `SetSelected { col: cols }` past the grid and open the editor on no cell.\n\nFix: add a lockstep case, not a unit test. Put the cursor at the rightmost column of a non-records grid at a nonzero row, press i then Tab, and compare the selection and mode with the host. Relation: improvise-3tt4's acceptance test (lockstep Tab at the rightmost column, comparing buffers) may cover this incidentally. Check it against `right_unclamped` before closing either.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:19:59Z","created_by":"agent-review-256","updated_at":"2026-09-24T21:45:19Z","started_at":"2026-09-24T00:21:18Z","closed_at":"2026-09-24T21:45:19Z","close_reason":"Landed in main via #271 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-256"],"dependencies":[{"issue_id":"improvise-qnry","depends_on_id":"improvise-3tt4","type":"related","created_at":"2026-09-24T00:20:09Z","created_by":"agent-review-256","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-98pd","title":"Three first-party sources still write non-item coordinates: set-cell, undeclared Data item, stale page selection","description":"Follow-up to improvise-gk51 (option (b) from that bead). gk51 made the projection and the refusal ask one predicate, `Model::first_non_item`, so a cell whose coordinate names no item now projects as `CellId::NonItem` and is offered to no one. That repairs the disagreement. It does not stop such coordinates from entering the document. Three first-party sources still create them:\n\n- **The `set-cell` script command.** `effect_cmds.rs` `SetCellCmd` writes the key as given and adds no item. For example, `set-cell 5 Region/Mars _Measure/Revenue`.\n- **A `.improv` `## Data` line naming an undeclared item.** `persistence/parse.rs` sets the cell and adds no item.\n- **A page selection naming no item.** `View::set_page_selection` does not validate. Every pivot cell's key then carries the stale item, and since gk51 the whole grid projects as `NonItem`. Found while testing gk51; the ticket did not predict it.\n\nThe fourth source, drill commits after an item is removed, is improvise-5mqm.\n\nDecide per source whether to refuse, to add the item, or to accept and let the projection decline, and write the decision down.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:17:27Z","created_by":"agent-viewhist","updated_at":"2026-09-24T00:17:27Z","dependencies":[{"issue_id":"improvise-98pd","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:20Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a61f","title":"improvise-wasm-client lib tests compile only under --workspace: test-support feature not requested in its dev-deps","description":"Found in the adversarial review of PR #259 at c349d14d. The same code is on gh/main 202e7eac, so the gap predates both PRs.\n\n`cargo test -p improvise-wasm-client --lib` does not compile:\n\nerror[E0277]: the trait bound `FormulaEntry: From\u003c\u0026str\u003e` is not satisfied\n --\u003e crates/improvise-wasm-client/src/render.rs:793\n\n`impl From\u003c\u0026str\u003e for FormulaEntry` is `#[cfg(any(test, feature = \"test-support\"))]` in improvise-protocol/src/render.rs:757. The only crate that enables `test-support` is improvise-command, in its own [dev-dependencies]. improvise-wasm-client's dev-dependencies do not request it. `cargo test --workspace` compiles only because feature unification pulls the feature in from improvise-command's test build, which is the same shape repo-map's Wasm section warns about for the wasm job.\n\nCI runs only --workspace, so it cannot see this. It bites anyone who runs one crate's tests, and it would bite a per-crate CI split.\n\nFix candidate: add `improvise-protocol = { path = \"../improvise-protocol\", features = [\"test-support\"] }` to improvise-wasm-client's [dev-dependencies]. Check: `cargo test -p improvise-wasm-client --lib` compiles standalone. It is also worth a sweep: run `cargo test -p \u003ccrate\u003e --no-run` for every workspace member.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:26Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T21:45:05Z","started_at":"2026-09-24T00:20:20Z","closed_at":"2026-09-24T21:45:05Z","close_reason":"Landed in main via #270 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a0i3","title":"shipped_documents_load: GIT_LITERAL_PATHSPECS or GIT_GLOB_PATHSPECS in the caller's env fail 9 of 10 tests","description":"Found in the adversarial review of PR #260 at 322de370 (git 2.55.0). tests/shipped_documents_load.rs::git() removes only the names that git rev-parse --local-env-vars lists, and the pathspec-magic variables are not in that list. shipped_documents() runs ls-files -- '*.improv' '*.improv.gz'.\n\nMeasured:\n- GIT_LITERAL_PATHSPECS=1 cargo test --test shipped_documents_load: 1 passed, 9 failed. every_shipped_document_loads fails its \"at least one shipped document\" precondition (line 33), because the globs are read literally.\n- GIT_GLOB_PATHSPECS=1: the same 1 passed, 9 failed, because '*' does not cross '/' under glob magic.\n\nThe failures are loud, not vacuous passes. This is the same spurious-red class as improvise-53zz, improvise-vwjj and improvise-pr3i, reached through pathspec parsing rather than through excludes.\n\nNot measured: GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS. GIT_ATTR_SOURCE=HEAD also fails loudly, with 8 of 10 failing on \"bad --attr-source\", as the pr3i ticket already notes.\n\nFix candidate: env_remove GIT_LITERAL_PATHSPECS, GIT_GLOB_PATHSPECS, GIT_NOGLOB_PATHSPECS, GIT_ICASE_PATHSPECS (and GIT_ATTR_SOURCE) in git(). Test: rerun_with_env with each one set, for a scratch-repo shape test AND for every_shipped_document_loads.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:25Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T21:45:56Z","closed_at":"2026-09-24T21:45:56Z","close_reason":"Landed in main via #269/#289 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-opbo","title":"GridLayout::evaluate_in/column_total accept a FoldMemo built by another layout and silently use its aggregation and hidden set","description":"Found reviewing PR #258 (improvise-pntu/0jw5, head 6b3577a0). ChatGPT Codex raised the same P2 on #258. After pntu, FoldMemo owns agg+covers, and GridLayout::evaluate_in(model, key, memo) / column_total(model, col, memo) accept ANY \u0026FoldMemo and ignore self's none_cats and hidden set. So layout_b.evaluate_in(.., \u0026layout_a.fold_memo()) evaluates under layout A's aggregation and hidden predicate.\n\nDemonstrated (scratch test, reverted): margin_book([\"R = Revenue / Cost\"]) with Q1 collapsed. View A shows all months; view B is A with Feb hidden. Key {Month=Q1,_Measure=R}. b.evaluate(key) = 2.0 (10/5); b.evaluate_in(key, \u0026a.fold_memo()) = 5.0 (40/8).\n\nIt is wrong on the memo's FIRST use, so here #258 is wider than #247, where a fresh foreign memo was harmless. Not reachable from production today: the only caller is viewport::grid_body, which builds layout.fold_memo() for the same layout. context/code/core.md section 14 names it as open (\"a memo built for one layout and passed to another's evaluate_in\"), but no ticket existed.\n\nFix shape: make the pairing unrepresentable. For example, layout.fold_memo() returns a per-build evaluator borrowing \u0026self that exposes evaluate(key)/column_total(col), and the memo-taking GridLayout methods go away. Test: the two-layout repro above, asserting b's reading through the shared evaluator equals b.evaluate.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:23Z","created_by":"agent-review-258","updated_at":"2026-09-24T21:45:15Z","started_at":"2026-09-24T00:30:38Z","closed_at":"2026-09-24T21:45:15Z","close_reason":"Landed in main via #268 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n4i9","title":"Type-ahead into an awaiting cell editor replaces the value the TUI extends: fast Tab Tab 5 stores 5 where the TUI stores 5095 (#227)","description":"Found while verifying improvise-mdfc on #227 (head 3fcd407b), in Chromium over the worker, with tall.improv and R01/Cost = 50 in the rightmost column.\n\nKeys `i 9 Tab Tab 5 Enter`, pressed back to back by Playwright, store 5095 or 5, depending on whether `5` arrives before the worker's answer. In 5 runs: 5095 twice, 5 three times. The TUI always stores 5095. With a 200 ms pause after the second Tab, the editor shows `509` and the browser stores 5095 in 5 of 5 runs. So the seed itself is right after mdfc; this is the type-ahead half.\n\nCause: the reopened editor is `OpenedEditor::awaiting()`, with an empty buffer. A key typed before the answer marks it touched, and `settle_awaited_seed` then never seeds it. The key REPLACES the value the TUI would have extended. #227 accepted this as a cost (\"what the user sees is what gets written\", protocol.md §3). It is never another record's number, but it does diverge from the TUI whenever the answer is slower than the next key: a held key, or any `?ws=` socket.\n\nOptions, none tried:\n(a) Record the characters typed while awaiting, and on settle write seed + typed. Backspace while awaiting needs a rule.\n(b) Paint the cached value dimmed as a placeholder while awaiting, as #227's review suggested, so the user at least sees that the value will be replaced.\n(c) Hold keys typed while an awaiting editor is unsettled until the answer arrives.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:09Z","created_by":"agent-227-fixes","updated_at":"2026-09-24T00:06:09Z","dependencies":[{"issue_id":"improvise-n4i9","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-29w2","title":"pr-watch-test: the CLOSED case no longer exercises the wait; closed PR #46 reports green MERGEABLE/BLOCKED and a rival passes 52/52","description":"Found by the delta review of PR #253 at 8cdea3a4 (b13355c7..8cdea3a4), which answers improvise-24lz.\n\nAt 8cdea3a4 the CLOSED fixture was changed from UNKNOWN/UNKNOWN to CONFLICTING/DIRTY, the shape closed PR #74 returns live. That shape is real, but it has a side effect. `mergeable == CONFLICTING` breaks the loop on the first poll whether the state check exists or not. So the CLOSED \"read once, not waited on\" case now holds by the loop's structure: no rival can fail it without also failing the CLOSED exit-code case.\n\nA different live closed shape does need the fix. Closed PR #46 has every check green and reports MERGEABLE/BLOCKED. A green BLOCKED rollup is waited on, and no test has that shape.\n\nRival (R8) that passes pr-watch-test.sh 52/52: exit 5 at the top of the loop for MERGED only, and exit 5 for CLOSED only after the loop breaks. Live against #46 with --timeout 15:\n- base 202e7eac: exit 3, \"timed out ... MERGEABLE/BLOCKED\"\n- R8: exit 3\n- 8cdea3a4: exit 5\n\nSo the CLOSED half of improvise-1qaa can regress with the suite green. Before 8cdea3a4, the rival \"state checked only at timeout\" also failed the CLOSED poll-count case. It no longer does: that case now passes, and only the exit-code case catches the rival.\n\nThe doc claim is also wrong. tooling.md at 8cdea3a4 says: \"A closed one need not: #74 reads CONFLICTING/DIRTY, which already ended the wait\". #46 shows a closed PR can hit the green-BLOCKED wait.\n\n## Steps to Reproduce\nApply R8 to scripts/pr-watch. Run scripts/pr-watch-test.sh and get 52 passed. Then run `scripts/pr-watch 46 --timeout 15`: it exits 3.\n\n## Acceptance Criteria\n- Add a CLOSED fixture in the #46 shape: MERGEABLE/BLOCKED with a green rollup, asserting exit 5 and one poll. Keep the #74 shape as well.\n- Confirm that R8 and \"state checked only at timeout\" each fail the new case.\n- Correct the tooling.md sentence.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T00:06:06Z","created_by":"agent-review-253","updated_at":"2026-09-24T21:45:57Z","closed_at":"2026-09-24T21:45:57Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sx7h","title":"#257's su4u restore survives commit-and-advance: Editing/RecordsEditing re-enter an equal mode, so a stale message follows the editor onto the next cell","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7.\n\n#257's `draw.rs::press` puts a stale status message back when the mode before a key has a minibuffer and the mode after it is EQUAL. The PR body and `context/code/tui.md` say that leaving the minibuffer, \"by a successful commit or by Esc\", drops the message. For two of the eight text-entry modes that is false. `Editing` and `RecordsEditing` commit with `commit-cell-edit` and `commit-and-advance-right` (`CommitAndAdvance`). That command drops to Normal, commits, advances, and then `EnterEditAtCursor` re-enters the same `AppMode::editing()`/`records_editing()` value. So the mode after an Enter or Tab that committed and moved to ANOTHER cell equals the mode before it. The commit writes no message, so the stale one is restored beside a different cell's editor. It keeps being restored across every commit until Esc or some key writes a message.\n\nReproduced with a scratch test driving `press`, not committed. Workbook: Region{North}, measures Cost/Revenue, one cell. Keys: `R`, `o`, `5`, Tab, `7`. After `o` the status is \"Added new record row\", in RecordsEditing at (1,0). Tab commits \"5\" and moves to (1,1). \"Added new record row\" is still `status_msg` and is still drawn beside `edit: 7`. With a second `o` on the existing row, the refusal \"That record row is already here — ... fill this one in first\" survives the Tab that filled the coordinate in. At base 202e7eac (#233) both vanish on the first key.\n\nWhy the test misses it: `a_minibuffer_refusal_stands_while_the_user_fixes_it` uses only FormulaEdit. That is the one-category fixture shape. FormulaEdit's successful commit leaves the mode AND writes \"Formula added\", so the test cannot tell \"same mode\" from \"same editor session\".\n\nMode equality stands in for \"the same minibuffer session\", and a commit that re-enters the same mode breaks it. Candidate discriminators, for the author to judge: the cursor/`opened_editor` also unchanged, or a restore only when the key did not reach a commit. The second is the keymap copy the PR rightly avoided. The test for the fix must press Tab in (Records)Editing with a stale message and assert that it is gone.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:18Z","created_by":"agent-review-257","updated_at":"2026-09-24T21:45:21Z","started_at":"2026-09-24T00:10:48Z","closed_at":"2026-09-24T21:45:21Z","close_reason":"Landed in main via #257 (reviewed; merged 2026-09-24).","labels":["adversarial-review","area:tui","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gk51","title":"Wire writes to a records-mode row with a non-item coordinate are projected as Record and then refused (#252 regression)","description":"Found in adversarial review of PR #252 (improvise-pr8t/nacd), head 47b166ee. Introduced by that PR.\n\n#252 makes App::non_item_refusal refuse a wire SetCell/ClearCell unless every coordinate is in Model::effective_item_names. The projection was not changed. In records mode, project::cell_id projects every measure cell of every stored record as CellId::Record. That includes a record whose category coordinate names no item. So the host now offers a cell as editable and then refuses the write. ADR 002 says the kind of identity is the permission, and command.md says the refusal asks \"the projection's own question, never a second derivation of it\". This is a second derivation, and it disagrees.\n\nSuch records exist, and first-party paths make them:\n- the script/`:` command `set-cell 5 Region/Mars _Measure/Revenue` (effect_cmds.rs SetCellCmd) adds no item;\n- a `.improv` `## Data` line naming an undeclared item (parse.rs sets the cell, adds no item);\n- drill commits after an item is removed (improvise-5mqm).\n\nProbe (reviewer's, not pushed): records mode (_Index Row, _Dim Column), Region={East}, _Measure={Revenue}, stored {Region=East,Revenue}=1 and {Region=Mars,Revenue}=5.\n- At 47b166ee, cell_id gives Record for (1,2), and unwritable returns \"Mars is no item of Region\". A caught-up wire SetCell is answered Rejected(MutationRefused).\n- Under the b114 rule (base 202e7eac), the same write is Accepted.\nThe TUI key path still edits the same cell, so the two front ends now disagree.\n\nFix shape: make the projection and the refusal ask one predicate. Either records-mode cell_id projects such a row as not-a-Record (e.g. Coordinate/Aggregate), or both call a shared \"names only items\" helper. Alternatively, close the root: set-cell and parse add the item, and 5mqm stops writing orphans. Pin it with an enumeration: every CellId::Record the host projects, in every view and in records mode, has unwritable == None.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:37Z","created_by":"agent-review-252","updated_at":"2026-09-24T21:45:52Z","closed_at":"2026-09-24T21:45:52Z","close_reason":"Landed in main via #265/#292 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-252"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4v9f","title":"A browser rename onto an existing destination outside the grid window is refused RecordUnstated on every retry and reopen (milr)","description":"Found reviewing PR #251 at head 29d2adef (improvise-milr's fix). The server now checks a rename's destination (`records_at_stake`), and the client claims `seen`, which is the Basis it held at open. The server publishes that Basis only for the records in the client's current grid window (`published_basis(app, \u0026self.grid)`), and `BasisCache::apply` replaces the whole cache each time. So a destination outside the window is never claimed, and the rename is refused `RecordUnstated`.\n\n`context/code/clients.md` accepts that refusal as the right direction to err. What it does not say: **no retry, and no reopen of the editor, can ever succeed** unless the source row and the destination row are in the window at the same moment. The cursor must be on the source when the editor opens, so for records that sort far apart (for example with 100 regions, renaming Aardvark onto Zulu) a browser can never merge the two. The TUI key path can. After this PR the only way out is resizing the window.\n\nProbe (real Host, `three_records_host`, a viewport of 1 row, rename North onto the existing West): `basis records known: 1`, then `Rejected(RecordUnstated(West))` three times in a row, with the text retyped between attempts. North is never renamed.\n\nThis needs a decision, not necessarily code:\n- (a) Accept it and document it in `clients.md`, next to kndq's policy. It may even be the right answer if kndq decides to refuse such merges anyway.\n- (b) Let the host publish the destination's counter on demand.\n- (c) Make `RecordUnstated` on a rename's destination forceable.\n\nThe ticket is linked to kndq because kndq's policy decides whether merging onto an existing record should be allowed at all.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T23:59:05Z","created_by":"agent-review-221-251","updated_at":"2026-09-23T23:59:05Z","labels":["adversarial-review","pr-251"],"dependencies":[{"issue_id":"improvise-4v9f","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:58Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-4v9f","depends_on_id":"improvise-kndq","type":"related","created_at":"2026-09-23T23:59:12Z","created_by":"agent-review-221-251","metadata":"{}"},{"issue_id":"improvise-4v9f","depends_on_id":"improvise-milr","type":"related","created_at":"2026-09-23T23:59:14Z","created_by":"agent-review-221-251","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pr3i","title":"Scratch repos inherit info/exclude from GIT_TEMPLATE_DIR","description":"tests/shipped_documents_load.rs's scratch repositories still take a user-level `info/exclude` from `GIT_TEMPLATE_DIR`. `git init` copies the template directory into the new `.git`, and `git rev-parse --local-env-vars` does not list `GIT_TEMPLATE_DIR`, so `git()` does not remove it. `GIT_CONFIG_GLOBAL=/dev/null` nulls `init.templateDir` in config, but the env var bypasses config.\n\nReproduced at PR #255 head 56ac72a2 (git 2.55.0): with `GIT_TEMPLATE_DIR=\u003cdir\u003e`, where `\u003cdir\u003e/info/exclude` holds `*.gz`, `a_gzipped_document_is_walked` FAILS at `git add` (\"Use -f if you really want to add them\"). This is the same symptom as improvise-53zz and improvise-vwjj, reached by a third path.\n\nFix candidates: `env_remove(\"GIT_TEMPLATE_DIR\")` in `run_git`, or `init --template=` with an empty value in `scratch_repo`. Test: rerun_with_env with GIT_TEMPLATE_DIR pointing at a dir whose info/exclude holds `*.gz`.\n\nAdjacent, not reproduced as user-level: the system gitattributes (`git var GIT_ATTR_SYSTEM`) is not disabled by GIT_CONFIG_NOSYSTEM; it needs GIT_ATTR_NOSYSTEM=1. GIT_ATTR_SOURCE fails loudly (\"bad --attr-source\"), not silently.\n\nFound in the adversarial review of #255.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:56:07Z","created_by":"agent-review-255","updated_at":"2026-09-24T21:45:19Z","started_at":"2026-09-23T23:58:13Z","closed_at":"2026-09-24T21:45:19Z","close_reason":"Landed in main via #260 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6u6s","title":"e265's enumeration has no non-contiguous group, so remark's run identity by header position (h0iv) is unguarded: a name-keyed rival passes all three tests","description":"Found reviewing PR #250 (improvise-e265) at d6e366dc.\n\nremark (prune_axis_items) keys each open run by its header's POSITION, so two runs under two equal headers stay two (context/code/core.md, the e265 section, claims this for h0iv). Nothing tests it. The TWO/THREE fixtures in layout.rs group_membership have every group's items contiguous under each parent, and a same-name run under a different parent is always separated by an outer-level change, which truncates the run stack anyway.\n\nRival, injected and run: key the input's open run by hash(level, cat_name, group_name) in place of the header's position. All three group_membership tests pass. A scratch test with the fixture Month = [Jan in Q1, Mar ungrouped, Feb in Q1] under North in Upper, which checks per-level run identity between consecutive surviving rows (not just innermost), fails at 'mask 0b101, collapsed []: run identity at level 1, row 1': pruning Mar merges North's two Q1 runs into one.\n\nThe ticket's own enumeration (300,580 cases) named non-contiguous groups; the PR's dropped them. Two gaps to close: (1) add a non-contiguous fixture, and ideally a hole level (a middle category with no groups); (2) assert the whole open-run stack per level, plus same-run-as-previous-row per level, not only innermost_group. The real code passes both on all five fixtures (TWO, THREE, NONCONTIG, HOLE, and an outer axis with two adjacent groups), 5,856 cases.\n\nCost note: the enumeration is already ~0.9-1.7s of improvise-core's lib binary (the rest is 0.16-0.18s, M4 Max under load avg 60-160). Adding fixtures should come with a cheaper construction: build each fixture's layout once per collapse subset and drive prune_axis_items with a keep predicate per mask, not two Workbook builds per mask.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T23:54:33Z","created_by":"agent-review-250","updated_at":"2026-09-24T21:45:00Z","started_at":"2026-09-23T23:57:15Z","closed_at":"2026-09-24T21:45:00Z","close_reason":"Landed in main via #263 (reviewed; merged 2026-09-24).","dependencies":[{"issue_id":"improvise-6u6s","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-23T23:54:38Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-24lz","title":"pr-watch: a PR whose head moved and then merged exits 5, not 2; exit-5 ordering against the head checks is unpinned","description":"Found by the adversarial review of PR #253 at b13355c7 (improvise-1qaa).\n\npr-watch checks `state` AFTER the --expect check but BEFORE the head-pin check. The two head checks guard the same hazard: a verdict about a tree that no longer exists (PR #67 merged at a head that predated its fix). Exit 5 now wins over one of them and not the other:\n\n- merged at another head, with --expect aaaa: exit 2 (correct; also confirmed live on #216 with --expect deadbeef)\n- no --expect, first poll head a OPEN and running, next poll head b MERGED: exit 5, with the message \"PR 1 is MERGED, nothing to wait for\". The watcher never saw b, and the message does not name the head.\n\nSKILL.md and tooling.md describe exit 5 as \"someone else already merged it\", a normal answer the caller should hear as that. So a caller following the docs accepts a merge at a head nobody watched. That is exactly what the pin exists to refuse.\n\nFix: move the state check below the pin block. On the first poll the pin block only assigns, so \"ends on the first poll\" still holds. Consider printing the head in the exit-5 message too.\n\nNeither ordering is pinned by a test. Both rivals pass pr-watch-test.sh 50/50: state check moved above --expect, and state check moved below the pin. Add two cases: moved-then-merged, and merged-at-another-head with --expect.\n\nDoc claims to correct in the same change:\n(1) tooling.md says GitHub reports a MERGED **or CLOSED** PR as UNKNOWN/UNKNOWN permanently. Live, closed PR #74 reports CONFLICTING/DIRTY, so the CLOSED fixtures are an invented shape. The behaviour is unaffected, because state is read first.\n(2) SKILL.md says exit 5 \"ends on the first poll\". It ends on the first poll that SEES the PR closed, which may be mid-wait.\n","notes":"REGRESSION introduced by PR #253, not a pre-existing gap. Ran the same moved-then-merged fixture against the base script at 202e7eac: exit 2, 'head moved aaaaaaaaaaaa -\u003e bbbbbbbbbbbb during the wait'. At b13355c7: exit 5.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T18:33:04Z","created_by":"agent-review-253","updated_at":"2026-09-24T21:45:57Z","closed_at":"2026-09-24T21:45:57Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-253"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8wy9","title":"App::restore's layout rebuild is unguarded: removing it leaves the workspace green","description":"Found by the adversarial review of PR #254 (improvise-4h8a) at head 67438b8f.\n\n`App::restore` ends with `self.rebuild_layout()`. `app.layout` is derived state, not an `AppSnapshot` field, so the new `snapshot_restore` tests never compare it.\n\nRIVAL THAT PASSES: delete the `self.rebuild_layout();` line from `App::restore`. `cargo test --workspace --no-fail-fast` stays fully green (39 binaries ok, exit 0).\n\nWHY IT MATTERS: `Host::undo_frame` calls `restore` and does not rebuild afterwards. `App::resolve_aggregate` and `record_in_layout` read `self.layout` through `layout_cell_keys`. So after a refused frame, the next frame's `Checked::against` would resolve surrogates against the grid of the frame that was rolled back, until some later `apply_effects` rebuilds it. The code is correct today. This is the same class as 4h8a: nothing stops a simplification from removing the rebuild.\n\nCHEAP FIX: in `snapshot_restore::restore_puts_back_every_field_the_snapshot_took` (crates/improvise-command/src/ui/app.rs), have the fixture change the grid's shape (for example, add an item under the added category, or set an axis), rebuild, and then after `restore` assert that the layout is equal to a freshly rebuilt one. `format!(\"{:?}\", app.layout)` works, or compare `row_count`/`col_count` and the labels. Then run the rival above and watch it fail.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:31:21Z","created_by":"Edward Langley","updated_at":"2026-09-24T21:45:04Z","started_at":"2026-09-23T23:54:31Z","closed_at":"2026-09-24T21:45:04Z","close_reason":"Landed in main via #261 (reviewed; merged 2026-09-24).","labels":["adr-001","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cota","title":"bd-audit-ids skips an absent --assume-unchanged file as deleted: ls-files -d lists it, git status does not","description":"Found in adversarial delta review of PR #244 at head 33d563a9 (fix acecc63d, improvise-tdfd).\n\nThe tdfd fix skips an absent tracked file only when `git ls-files -d` lists it. But `ls-files -d` also lists an absent file marked `--assume-unchanged`. `git status` shows nothing for that file, so `commit -a` never records the deletion.\n\nThe audit therefore skips a file whose citations stay in HEAD, and exits 0. This is the same shape as tdfd: absent, not a deletion git will record, never read.\n\nReproduce in a throwaway repo:\n1. Commit `context/dead.md` citing an unknown id.\n2. Run `git update-index --assume-unchanged context/dead.md`, then `rm context/dead.md`.\n3. `git status --short` is empty. `git ls-files -d` prints `context/dead.md`.\n4. The audit prints \"all 1 cited ids resolve\" and exits 0.\n5. Control: without step 2 (a plain unstaged delete), the audit also exits 0. That is by design.\n\nRare, and CI checks out fresh, so P3. Fix: ask a deletion oracle that honours the index flags, such as `git diff --name-only --diff-filter=D`, which assume-unchanged hides. Alternatively, exclude `h`-flagged entries of `ls-files -v`. Add a case beside the skip-worktree case.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:29:15Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T21:45:08Z","started_at":"2026-09-24T00:02:35Z","closed_at":"2026-09-24T21:45:08Z","close_reason":"Landed in main via #262/#290 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rq3f","title":"Browser Tab-commit's advance-right and the tile cursor's upper clamp are unasserted: breaking either passes the whole workspace","description":"Found in adversarial delta review of PR #246 at 790a33ba. This predates #246. The resolver code is untouched by that PR.\n\nTwo faults injected into `crates/improvise-protocol/src/resolve.rs` each pass the whole workspace (`cargo test --workspace --no-fail-fast`: 1945 passed, 0 failed):\n\n- `advanced`: `Advance::Right =\u003e Some((row, (col + 1).min(col_max)))` changed to `Some((row, col))`. So `commit-and-advance-right` (Tab in an editor) in the browser no longer advances, and nothing notices.\n- `tile_cursor`: the upper clamp `(count - 1) as i32` changed to `count as i32`. So moving right from the last tile selects an index past the end, and nothing notices.\n\nFor comparison, five other resolver faults (page_rows, scroll offset, panel cursor, gg, G) are each caught by a named lockstep test. So these two are gaps in coverage, not a sign that lockstep is blind in general.\n\nFix: add a lockstep case for each, with a cursor at a nonzero index. Tab-commit should land where the host's CommitAndAdvance lands. l or Right on the last tile should agree with the host's MoveTileCursor.\n","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:29Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:20Z","started_at":"2026-09-23T18:23:45Z","closed_at":"2026-09-24T21:45:20Z","close_reason":"Landed in main via #256 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xoxh","title":"lockstep.rs deliver() folds host-sent frames through fold_outgoing, and one merged-in test still folds its own commands through fold","description":"Found in adversarial delta review of PR #246 at 790a33ba.\n\n1. `crates/improvise-wasm-client/tests/lockstep.rs::deliver` folds the HOST's `ServerFrame::Commands` through `client.fold_outgoing(\u0026cmds)`. e0dec966 changed it from `fold` along with the 19 loops that fold the client's own resolved commands. But `deliver` models what the host sends, which `context/code/clients.md` says is `fold`'s door. The PR comment says \"the two remaining `fold` calls model what the host sends\", but `deliver` is a third host-side call, and it is on the wrong door.\n\n Latent today: `deliver`'s only caller (`switching_between_a_records_view_and_a_pivot_view_settles_both_ways`) sends view-only frames, so nothing is awaited and both doors settle immediately. With an awaited frame in flight, `fold_outgoing` would queue the host's relays at the BACK as the client's own frame, not directly behind the head as `settle_relay` does. That is the pre-jpba ordering, so a test built on `deliver` could not see a jpba regression.\n\n2. `renaming_a_formula_from_the_panel_lands_the_same_document_as_the_host`, which arrived from gh/main in the merge 790a33ba, still folds the client's own resolved commands through `client.fold(cmd)`. So after the merge, \"every loop over the client's own commands uses fold_outgoing\" is not true at the head.\n\nFix: `deliver` goes back to `fold` for `ServerFrame::Commands`, and the renaming test uses `fold_outgoing`.\n","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:20:19Z","created_by":"agent-review-246b","updated_at":"2026-09-24T21:45:26Z","started_at":"2026-09-23T18:29:43Z","closed_at":"2026-09-24T21:45:26Z","close_reason":"Landed in main via #259 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vwjj","title":"The scratch-repo tests still read the user's default excludes file (XDG git/ignore), so a *.gz there fails a_gzipped_document_is_walked despite GIT_CONFIG_GLOBAL=/dev/null","description":"Found reviewing PR #248 (improvise-53zz) at head f5cfc7ed.\n\n`run_git` sets `GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, which stops git reading the user's global and system config files. It does not stop git reading the user's **default** excludes file. When `core.excludesFile` is unset, git falls back to `$XDG_CONFIG_HOME/git/ignore`, or `~/.config/git/ignore` when XDG_CONFIG_HOME is unset. That fallback does not depend on any config file, so both variables leave it in force. It is also the most common place for a global ignore, because it needs no config line.\n\nMeasured on git 2.55.0 at f5cfc7ed. A probe test re-ran `a_gzipped_document_is_walked` through the PR's own `rerun_with_env` with `XDG_CONFIG_HOME=\u003ctmp\u003e` and `\u003ctmp\u003e/git/ignore` containing `*.gz`. The child failed at `git add`: \"The following paths are ignored by one of your .gitignore files: docs/model.improv.gz\". Setting `HOME=\u003ctmp\u003e` with `\u003ctmp\u003e/.config/git/ignore` fails the same way. That is 53zz's symptom 2 on the path the fix does not cover. The PR's test sets `core.excludesFile` explicitly inside a config file, so it cannot see this.\n\nFix options, cheapest first: pass `-c core.excludesFile=/dev/null` in `run_git` (a `-c` argument, which `--local-env-vars` does not strip), or point `XDG_CONFIG_HOME` and `HOME` at an empty temp dir. Also consider `core.attributesFile`, which has the same XDG default. Rival: run the gzip shape test in a child with `XDG_CONFIG_HOME` pointing at a dir whose `git/ignore` holds `*.gz`, and assert it passes.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T18:17:06Z","created_by":"agent-review-248","updated_at":"2026-09-24T21:45:24Z","started_at":"2026-09-23T18:24:02Z","closed_at":"2026-09-24T21:45:24Z","close_reason":"Landed in main via #255 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-metf","title":"prune_empty evaluates every cell with a fresh fold memo","description":"Found reviewing #247 (improvise-h6z1): GridLayout::prune_empty (view/layout.rs ~620) evaluates each cell with its own fresh memo — the same shape h6z1 fixed in grid_body, on the same keypress path. Measure first on the folded-chain bench; share one memo per build if it pays.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:14:55Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:14Z","started_at":"2026-09-24T00:05:23Z","closed_at":"2026-09-24T21:45:14Z","close_reason":"Landed in main via #268 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0jw5","title":"Nothing guards that viewport::grid_body shares one FoldMemo: a per-cell fresh memo there passes the whole suite","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc). The PR's whole effect is grid_body making one FoldMemo and passing it to cell_value and column_total. Rival: in viewport.rs cell_value, replace the memo with FoldMemo::default() per cell. cargo test --workspace --no-fail-fast: zero failures. a_fold_row_evaluates_each_formula_once_across_the_row calls GridLayout::evaluate_in directly with a memo of its own, so it pins the layout-level API and not the build that is supposed to use it; the totals' memo is likewise unguarded. Only benches/grid_projection.rs (folded chain ~18k: 1.19s -\u003e 0.52s measured) can see a regression, and it is not a gate. Add a test in improvise-command that counts FOLD_FORMULA_EVALUATIONS (or an equivalent observable) across one grid_body call on a folded chain and asserts 9 per fold row, with the per-cell rival shown failing. Note the counter is #[cfg(test)] pub(crate) in improvise-core, so it is not visible from improvise-command as-is.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:31Z","created_by":"agent-review-247","updated_at":"2026-09-24T21:44:40Z","started_at":"2026-09-23T18:24:44Z","closed_at":"2026-09-24T21:44:40Z","close_reason":"Landed in main via #258 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pntu","title":"FoldMemo's key omits the aggregation and the covers predicate: one memo shared across two covers returns the first answer","description":"Found reviewing PR #247 (improvise-h6z1, head 4f75fffc); also ChatGPT Codex's P2 on that PR. FoldMemo is pub and keyed by (CellKey, span.over). evaluate_covering_in / evaluate_spanning_in are pub and take any agg and covers. Demonstrated: margin_book(['R = Revenue / Cost']), key {Month=Q1, _Measure=R}, Aggregation::Exact. evaluate_covering_in with covers=all then with covers=hide-Feb on ONE memo returns 5.0 for the second call; a fresh memo returns 2.0. Not reachable from viewport::grid_body today (agg and covers are constant per build), which is why the suite is green. The PR includes span in the key precisely because the _in functions are public; agg and covers are the same argument left unanswered. Fix shape: bind agg+covers into the memo (e.g. a per-build evaluator object that owns memo, agg and covers, so the _in functions cannot be handed a mismatched pair), rather than widening the key with a closure identity. Test: the one above, asserting the second call equals a fresh evaluation.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T18:13:20Z","created_by":"agent-review-247","updated_at":"2026-09-24T21:44:41Z","started_at":"2026-09-23T18:24:44Z","closed_at":"2026-09-24T21:44:41Z","close_reason":"Landed in main via #258 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nmm5","title":"Lockstep never exercises a refusal: a ClientState refusal-ordering regression is visible only to client.rs unit tests","description":"Found while fixing improvise-3kfc (PR #246). tests/lockstep.rs now folds the client's own commands through fold_outgoing, which is the right door, but it never refuses a frame. So a regression in how ClientState orders a refusal against queued relays or view-only frames is caught only by the client.rs unit tests and the two Host-level jpba tests. Add at least one lockstep case that sends two frames, has the host refuse the first, and compares client and mirror after both answers.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T18:10:26Z","created_by":"orchestrator","updated_at":"2026-09-23T18:10:26Z","dependencies":[{"issue_id":"improvise-nmm5","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tdfd","title":"bd-audit-ids silently skips a tracked file absent by skip-worktree or sparse checkout, not only a deleted one","description":"PR #244 head e2649820 (ChatGPT Codex P2, confirmed). The deleted-file filter keeps a path only if `[ -e \"$f\" ] || [ -L \"$f\" ]`, so ANY tracked path absent from the working tree is dropped, not only a deleted one.\n\nReproduced in a detached worktree at e2649820: context/adr/001-concurrent-edits.md holds the only citation of improvise-k2i4. With a stub bd (BD_AUDIT_BD) that knows every other cited id, the audit exits 1. After marking that file skip-worktree and moving it out of the tree, it exits 0 (\"all 498 cited ids resolve\").\n\n`ls-files -d` does NOT list the skip-worktree path, so skipping only what `ls-files -d` reports, and treating any other missing tracked path as exit 4, discriminates. Needs a skip-worktree case beside the deleted-file case.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:48Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:45:22Z","started_at":"2026-09-23T18:16:08Z","closed_at":"2026-09-24T21:45:22Z","close_reason":"Landed in main via #244 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-244"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fd0j","title":"pr-reviews-test watchdog needs bash 5.1 but checkers.sh pins only major 5","description":"PR #237 head 785202b8 (ChatGPT Codex P2, confirmed). bounded_reviews runs `wait -n -p done \"$pid\" \"$dog\"`; the -p option is bash 5.1. find_modern_bash in scripts/checkers.sh accepts any BASH_VERSINFO[0] \u003e= 5, so a bash 5.0 host is accepted and the suite dies (`done` unbound under set -u).\n\nDemonstrated shape under macOS /bin/bash 3.2 run directly: 2 passed, 23 failed, where gh/main's suite is 25/25 under 3.2. CI (5.2) and nix (5.3) are fine, so this is a contract mismatch, not a live CI failure.\n\nFix: raise find_modern_bash's floor to 5.1 (with a checkers-test case for 5.0), or detect the watchdog winner without -p.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:47Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:45:02Z","started_at":"2026-09-23T18:16:06Z","closed_at":"2026-09-24T21:45:02Z","close_reason":"Landed in main via #237 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-237"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bbwa","title":"check-ci-graph counts a --ci step in a job that needs: a job which can be skipped; the dependent job is skipped and a skipped check reads as passing","description":"At PR #228 head d6706774: add a job 'gate' with job-level 'if: false' (no checkers.sh step, so nothing disqualifies it), give kb 'needs: [gate]', and add gate to pages-deploy's needs. check-ci-graph exits 0 with all 9 suites counted, but on a PR GitHub skips kb (its dependency was skipped) and a skipped required check counts as success. Same class as improvise-tvte (a condition written around the call), one hop away: the disqualifiers are read only on the counted job. build/js/kb have no needs: today. Cheapest close: treat a job-level needs: as a disqualifier for the counted step, or require every needed job to be unconditional.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T17:59:22Z","created_by":"agent-review-tooling4","updated_at":"2026-09-24T21:44:43Z","started_at":"2026-09-23T18:02:24Z","closed_at":"2026-09-24T21:44:43Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nacd","title":"Wire SetCell on a stored record with a _Measure that is no item (unknown name, or a parent-only group) is accepted and stores a non-item coordinate","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing: the PR does not introduce it, and it does not close it.\n\nPR #235's rule is \"no coordinate names a group\". The invariant it protects\n(improvise-gw18) is wider: \"every coordinate names an item\". Two writes fall in\nthe gap. Both are accepted at 5c19fdd4 and store a non-item coordinate:\n\n- A caught-up wire SetCell on the stored record `{Month=Feb}` with measure\n `Bogus`, which is no item and no group of `_Measure`. It stores\n `Month=Feb, _Measure=Bogus = 7`.\n- The same write with measure `Totals`, where `Totals` is a PARENT group\n (`Group::new(\"Money\").with_parent(\"Totals\")`) that holds no item directly.\n `items_in_group(\"Totals\")` is empty, so `group_refusal` does not see it.\n It stores `_Measure=Totals = 7`.\n\nNeither cell is drawn in any pivot view, which is the gw18 shape: invisible data\nthat persists through format_md and counts in records mode. The record half is\nsafe, because an unknown record does not resolve. The measure half is unchecked\nwhenever the record is stored.\n\nThreat model is the same as b114/xur5: a hand-rolled or stale client. No honest\nclient composes these writes.\n\nProbe: `review_235::probe_parent_only_group_and_unknown_measure` (reviewer's,\nnot pushed). Fix shape: refuse a `_Measure` coordinate that is not in\n`measure_item_names()` (items plus formula targets; formula targets are refused\nanyway by the Derived rule). Or generalise `group_refusal` to \"names no item of\nits category\".\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:17Z","started_at":"2026-09-23T18:11:47Z","closed_at":"2026-09-24T21:45:17Z","close_reason":"Landed in main via #252 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-235"],"comments":[{"id":"ec47fc8d-7311-511f-a2ee-dd5c6345723d","issue_id":"improvise-nacd","author":"agent-review-221-251","text":"Reachable by an ordinary race since PR #274 (seen at 3dad3b09), not only by a hand-built frame. A captures a keyboard editor on North/Cost. B sends RemoveItem(_Measure, Cost), accepted and delivered. A presses Enter. The captured OpenedCell still names measure Cost, the record basis passes because North's version did not move, and the host Accepts SetCell North/Cost = 42 with Cost no longer an item. The old cursor guard would not have caught it either: the reset leaves the cursor on a Record.","created_at":"2026-09-24T03:24:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-vwc7","title":"An item may share its name with a group of its category, so a key naming it is ambiguous: a wire write to the item is refused in rows and accepted in _Measure","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4. Pre-existing model gap. #235 makes it matter, and PR #221 (improvise-fdk1) will add a second way in.\n\n`Category` lets an item and a group share a name. `add_item` and\n`add_item_in_group` check only `items`, and `add_group` checks only `groups`.\nOnce they share a name, a key naming it is ambiguous: `Month=Q2` is both the\nitem Q2's row and the collapsed group Q2's fold row.\n\nThe wire behaves inconsistently, as measured at 5c19fdd4 with\n[AddItem, SetCell] frames that carry the SetCell's basis:\n\n- `_Measure`: AddItem `Money` (Money is already a group of Revenue and Cost),\n then SetCell `{Feb, Money}`. The frame is **accepted** and stored.\n- Row category: in `host_on_a_fold` (Q2 collapsed), AddItem `Month=Q2`, then\n SetCell `{Q2, Revenue}`. The frame is **refused** (`MutationRefused`) and\n undone. The new record is unstored, so `aggregate_refusal` finds the key by\n position, finds the FOLD row, and sees an Aggregate. A write to a real item\n was refused because the fold shares its key.\n\n#235's `group_refusal` exempts a name that is also an item, which is the right\ncall given the model. But the root is the namespace, not the refusal.\nimprovise-fdk1's `RenameRecordCoordinate` (PR #221) creates the target item\n\"if need be\", so a rename onto a group's name will create exactly this\nnamesake. So will the TUI's records-mode category edit.\n\nDecide one of: (a) the model refuses an item named like a group of its category,\nand a group named like an item (smart constructors); or (b) keys disambiguate a\nfold from an item. (a) is the smaller change. Probe:\n`review_235::probe_add_item_then_write_in_one_frame` (reviewer's, not pushed).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:42Z","created_by":"agent-review-235-239","updated_at":"2026-09-23T16:16:42Z","labels":["adversarial-review","pr-235"],"dependencies":[{"issue_id":"improvise-vwc7","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pr8t","title":"#235's same-name-item exemption in group_refusal is unpinned: removing it refuses a real item and the workspace stays green","description":"Found in adversarial review of PR #235 (improvise-b114/xur5), head 5c19fdd4.\n\n`App::group_refusal` exempts a name that is also an item of the same category\n(`!category.items.contains_key(name)`). Nothing pins that exemption. With it\nremoved (`let group = true \u0026\u0026 !items_in_group(name).is_empty()`), the full\nworkspace suite stays green (`cargo test --workspace --no-fail-fast`, 0 failed).\n\nUnder that rival, a real item is refused. Fixture: `_Measure` has Revenue and\nCost in group Money, and ALSO an ungrouped item named Money\n(`Category` permits that: `add_item` checks only `items`). The stored cell\n`{Month=Feb, _Measure=Money}` is a legitimate item cell. At 5c19fdd4 a caught-up\nwire SetCell on it is accepted (correct). With the rival it is refused.\n\nFix: one test that writes to an item whose name is also a group of its category\nand asserts Accepted and the stored value. The reviewer's probe is\n`review_235::probe_item_named_like_its_categorys_group_is_writable` (not pushed).\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:16:40Z","created_by":"agent-review-235-239","updated_at":"2026-09-24T21:45:18Z","started_at":"2026-09-23T18:11:44Z","closed_at":"2026-09-24T21:45:18Z","close_reason":"Landed in main via #252 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-235"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-su4u","title":"A minibuffer refusal (formula parse error) vanishes on the first correcting keystroke under #233's status expiry","description":"Found reviewing PR #233 (improvise-cq3d) at head d9ea5946.\n\n#233 clears `status_msg` at the start of every TUI keypress (`draw.rs::press`). In a\nminibuffer mode `minibuffer_line` draws the message on the right of the prompt, and\nsince improvise-v4hg a formula that does not parse keeps the user in `FormulaEdit`\nwith the error. The error now disappears on the first correcting keystroke:\n\n- type `Total = (` in the formula editor, press Enter: the row reads\n `formula: Total = (▌ ... Formula error: --\u003e 1:10`\n- press Backspace once: the row reads `formula: Total = ▌`, and the error is gone.\n\nThe user is shown the error once, but it is gone before they finish fixing the thing\nit describes. The same applies to any refusal that leaves the user in a minibuffer.\nBefore #233 the error stayed while they edited, and then stayed forever (the cq3d bug).\n\nPossible rule: in a text-entry mode, expire the message only when the key leaves the\nmode, or only on the commit key. Decide whether that is worth the extra rule; it is a\ndesign choice, not a crash. Reproduce it through `press`, not `handle_key`, because\nthe expiry is in the TUI loop only.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:43Z","created_by":"agent-review-233-242","updated_at":"2026-09-24T21:45:21Z","started_at":"2026-09-23T18:23:44Z","closed_at":"2026-09-24T21:45:21Z","close_reason":"Landed in main via #257 (reviewed; merged 2026-09-24).","labels":["adversarial-review","area:tui","pr-233"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m6op","title":"Cross-instance Debug fingerprint: Model.measure_agg's sort is pinned by no test","description":"Found reviewing PR #241 (head 3a4b0eac). Model's Debug sorts measure_agg (HashMap\u003cString, AggFunc\u003e) into a BTreeMap so two equal documents print alike. No committed test sees it: replacing .field(\"measure_agg\", \u0026aggs) with the raw HashMap left cargo test --workspace --no-fail-fast green in 3 of 3 runs. The only cross-instance comparison, a_model_command_lands_where_its_keypress_lands, has an empty measure_agg, so a HashMap of zero entries has one order (the same vacuity djcn fixed for View/DataStore). formula_cache's sort IS pinned (recomputing_formulas_leaves_the_model_fingerprint_alone fails when it is unsorted). A reviewer scratch test with two measure_agg entries failed 3/3 under the injection. Fix: give the pivoted() fixture two or more measure_agg entries (and ideally a formula so formula_cache is non-empty across instances too), then re-inject.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","updated_at":"2026-09-24T21:45:54Z","closed_at":"2026-09-24T21:45:54Z","close_reason":"Landed in main via #249 (reviewed; merged 2026-09-24).","dependencies":[{"issue_id":"improvise-m6op","depends_on_id":"improvise-djcn","type":"discovered-from","created_at":"2026-09-23T16:05:41Z","created_by":"agent-review-241","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cwfj","title":"The remaining set_status declines: panel.rs (3) and keymap dispatch's arg-error arm should refuse","description":"Remainder of improvise-i3o5 after PR #236. cmd/panel.rs:708 'Move cursor to a category header to change axis', :726 'No category selected…' and :818 'No category to delete' decline with set_status, so they exit 0 when run headless. command/keymap.rs::dispatch writes set_status(e) for a bound key whose constructor returns Err. ORCHESTRATOR RULING: that Err is a refusal (cli.md §10), so dispatch should refuse(e). Before converting dispatch, check nothing depends on a bound key's Err leaving last_batch_refused false. Assigned to agent-refuse-fu.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:47:49Z","created_by":"orchestrator","updated_at":"2026-09-25T00:59:59Z","closed_at":"2026-09-25T00:59:59Z","close_reason":"Landed in #238 (on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tvte","title":"check-ci-graph counts a checkers.sh invocation that never runs on a PR: step/job if: conditions and shell control flow are ignored","description":"Found by adversarial re-review of PR #228 at head c1417359.\n\ncheck-ci-graph.py counts an invocation that never runs on a PR. The count is silent, so a suite can be reported as run in CI when it is not. Two sources:\n\n1. **YAML conditions are ignored.** A step's `if:` and a job's `if:` are never read. A suite whose only invocation is in a `- if: failure()` step, or in a job gated like pages-deploy (`if: github.event_name == 'push' \u0026\u0026 github.ref == 'refs/heads/main'`), counts as run on every PR.\n2. **Shell control flow is ignored.** Each of these counts the suite, although it never runs:\n - `true || ./scripts/checkers.sh X`\n - `false \u0026\u0026 ./scripts/checkers.sh X`\n - `exit 0` on the line before the invocation, in a block\n - `if false; then` … `fi` around the invocation\n - the invocation inside a shell function that is never called\n\nEach was demonstrated with a probe over suite_verdict/suites_run, requiring pr-watch where the shape is its only invocation.\n\nThe shell half cannot be fully closed by static analysis. The YAML-condition half can be: refuse, or refuse to count, any invocation in a step or job that carries an `if:`. Whatever is not closed should be stated as a residual in context/code/tooling.md. At present that section says only what is caught.\n\nLower priority than the multi-line/heredoc fail-open. Every shape here needs someone to write a condition around the invocation. That filed ticket needs only a prose mention of the script.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:41:52Z","created_by":"agent-review-228b","updated_at":"2026-09-25T00:59:37Z","started_at":"2026-09-23T15:48:09Z","closed_at":"2026-09-25T00:59:37Z","close_reason":"Landed in #228 (d6706774 on gh/main: 'CI runs checkers.sh --ci \u003cjob\u003e; the gate reads a table, not shell')","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c0aj","title":"The shipped-documents walk reads only root !dir/*.improv lines, so a single-file un-ignore, .improv.gz or nested un-ignore is skipped silently","description":"Found reviewing PR #234 at head 91c80cb7 (improvise-qlrh). It does not block #234: today the derived set (examples, web, harness/fixtures) equals `git ls-files '*.improv'` exactly.\n\n`tests/shipped_documents_load.rs::shipped_directories` defines \"shipped\" as the directories named by root `.gitignore` lines of the form `!\u003cdir\u003e/*.improv`. Several ways of shipping a document fall outside that shape, and each one is missed silently:\n\n- **A single-file un-ignore.** Injected: I changed `!web/*.improv` to `!web/demo.improv` and replaced web/demo.improv with garbage. `git check-ignore` confirms the file is still un-ignored, and it stays tracked. The test PASSES, because examples/ and harness/fixtures still satisfy both preconditions.\n- **`.improv.gz`.** `*.improv` never ignores it, so a gzipped document can be tracked in any directory without an un-ignore line, and the walk never visits that directory.\n- **A `!` line in a nested .gitignore.** harness/.gitignore and web/.gitignore exist. Such a line would be missed, and its path is relative to that file, not to the root.\n- **A force-added (`add -f`) ignored document.**\n\nPatterns like `!/examples/*.improv` or `!**/x/*.improv` fail loudly (read_dir panics), so they are not silent. CRLF, comments and leading whitespace are handled (`lines()` + `trim()`).\n\nA direction that closes this: take the ground truth from the index (`ls-files '*.improv' '*.improv.gz'`) when the test runs in a checkout, or assert that the derived directories cover every tracked document.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:40:00Z","created_by":"agent-review-224b-234","updated_at":"2026-09-23T17:52:49Z","started_at":"2026-09-23T15:52:32Z","closed_at":"2026-09-23T17:52:49Z","close_reason":"Fixed in PR #243, merged as c26f016 after adversarial review at 228f130. Follow-up: improvise-53zz.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-djcn","title":"Model's hand-written Debug is not exhaustive, and the Debug fingerprint is not canonical across instances (DataStore/View HashMaps)","description":"Found reviewing PR #224 at head 38ec6061 (improvise-tt22 / improvise-pc3b). Neither half blocks #224.\n\n1. The hand-written `impl Debug for Model` (crates/improvise-core/src/model/types.rs) names its fields one by one through `debug_struct`, so a field added later is left out and no compiler error says so. Before #224, `#[derive(Debug)]` covered new fields automatically. Injected: I added `pub probe_field: u64` to `Model` and made `RebuildLayout` (ViewOnly) increment it. The workspace compiled and `view_only_effects_leave_shared_state_alone` passed. Fix: destructure `let Model { name, categories, data, formulas, next_category_id, measure_agg, formula_cache } = self;` so a new field is a compile error.\n\n2. The Debug fingerprint is canonical within one instance but not across two equal instances. `DataStore` (`index: HashMap\u003c_, HashSet\u003c_\u003e\u003e`, `records: HashMap`, `symbols.to_id: HashMap`) and `View` (`page_selections`, `hidden_items`, `collapsed_groups`) still derive Debug over HashMaps. A probe that built two equal data-bearing, view-bearing Apps got a different `format!(\"{:?}\", model)` in 32/32 runs, and a different `format!(\"{:?}\", views)` in 32/32. The effect-classification tests compare before and after on ONE instance, so they are sound: with a view-bearing fixture, all ten ViewOnly effects times 32 runs were stable. But `ui/reduce.rs::a_model_command_lands_where_its_keypress_lands` compares `format!(\"{:?}\", workbook)` across TWO Apps (`by_key` vs `by_command`). It is green only because its fixture has no cells and no multi-entry view maps. Give that fixture data and it flakes, the same shape as improvise-hzjo. Fix options: canonical (sorted) Debug for DataStore and View, or a real equality/fingerprint for cross-instance comparisons.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T15:39:42Z","created_by":"agent-review-224b-234","updated_at":"2026-09-23T17:52:50Z","started_at":"2026-09-23T15:41:47Z","closed_at":"2026-09-23T17:52:50Z","close_reason":"Fixed in PR #241, merged as d3f03f8 after adversarial review at 3a4b0ea. Follow-up: improvise-m6op.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-hv0r","title":"#230's echo is unpinned on its audience and its offset arms: echoing to every session, or only SetSelected, leaves the workspace green","description":"Found by the adversarial review of PR #230 (improvise-wdmp) at head 93c455db.\n\n#230's only test, `ui::host::tests::a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`, places with `SetSelected` alone. It checks that the bystander received `ResetCursor`, but never that it received nothing more. Two injected faults survive every test in improvise-command, improvise-ws-server, improvise-wasm-client and improvise-worker-server:\n\n1. **The echo addressed to every session**, not just the asker. The asker's position would then reach a bystander, which violates improvise-g0f (\"never a position\") and gotcha 36's rule that a bystander must be seated where a wrong relay can move it.\n2. **`places_cursor` narrowed to `SetSelected`**, dropping `SetRowOffset`/`SetColOffset`. This is reachable. A records `+` commit (`resolve::create_column` then `advanced` then `scrolled_to`) can emit offsets after a reframing `AddItem`. The client's offsets would then stay reset while its mirror's are placed.\n\nThe reviewer's `rv_place_reframe_place_echoes_only_the_later_placements` catches both. It sends `[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0), SetColOffset(1)]` and asserts three things:\n- the asker's echoes are exactly `[SetSelected(1,0), SetColOffset(1)]`, so the pre-reset `SetRowOffset` is not echoed;\n- the bystander hears no placement;\n- the echo follows the reset.\nIt also asserts client == mirror. Acceptance: tests of that shape land with #230, or right after it.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:31:43Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:34:13Z","started_at":"2026-09-23T15:34:13Z","lease_expires_at":"2026-09-23T15:39:13Z","heartbeat_at":"2026-09-23T15:34:13Z","labels":["adversarial-review","pr-230","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hv0r","title":"#230's echo is unpinned on its audience and its offset arms: echoing to every session, or only SetSelected, leaves the workspace green","description":"Found by the adversarial review of PR #230 (improvise-wdmp) at head 93c455db.\n\n#230's only test, `ui::host::tests::a_frame_that_reframes_and_then_places_the_cursor_leaves_the_asker_placed`, places with `SetSelected` alone. It checks that the bystander received `ResetCursor`, but never that it received nothing more. Two injected faults survive every test in improvise-command, improvise-ws-server, improvise-wasm-client and improvise-worker-server:\n\n1. **The echo addressed to every session**, not just the asker. The asker's position would then reach a bystander, which violates improvise-g0f (\"never a position\") and gotcha 36's rule that a bystander must be seated where a wrong relay can move it.\n2. **`places_cursor` narrowed to `SetSelected`**, dropping `SetRowOffset`/`SetColOffset`. This is reachable. A records `+` commit (`resolve::create_column` then `advanced` then `scrolled_to`) can emit offsets after a reframing `AddItem`. The client's offsets would then stay reset while its mirror's are placed.\n\nThe reviewer's `rv_place_reframe_place_echoes_only_the_later_placements` catches both. It sends `[SetRowOffset(1), ChangeMode, AddCategory, SetSelected(1,0), SetColOffset(1)]` and asserts three things:\n- the asker's echoes are exactly `[SetSelected(1,0), SetColOffset(1)]`, so the pre-reset `SetRowOffset` is not echoed;\n- the bystander hears no placement;\n- the echo follows the reset.\nIt also asserts client == mirror. Acceptance: tests of that shape land with #230, or right after it.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T15:31:43Z","created_by":"agent-review-227-230","updated_at":"2026-09-24T21:45:12Z","started_at":"2026-09-23T15:34:13Z","closed_at":"2026-09-24T21:45:12Z","close_reason":"Landed in main via #246 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-230","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xur5","title":"Wire SetCell on a collapsed-fold RECORD with an undrawn measure (hidden, unknown or none) is accepted and stores the group name as a coordinate: #217's whole-key lookup reads 'not drawn' as 'not refused'","description":"Found in the adversarial delta re-review of PR #217 (improvise-ciw8) at head 6026768c, which includes the sjdb fix.\n\nThe sjdb fix gives back part of what bc060f67 closed. The fix is `cell_position(key)` in place of `record_position(hash)`. Relative to gh/main this is NOT a regression, because before ciw8 nothing was refused.\n\n**Setup:** Month={Jan,Feb in Q1; Apr,May in Q2} on Row, with Q2 collapsed. `_Measure`={Revenue,Cost} on Column, with every month stored. The record {Month=Q2} is not stored, so `resolve_record` finds it only through the layout, at the Q2 fold row.\n\n**The write:** a caught-up wire `SetCell { record: RecordHash({Month=Q2}), measure: M }`. Results:\n\n| M | at bc060f67 (by record) | at 6026768c (by whole key) |\n|---|---|---|\n| Revenue (drawn: the fold cell) | refused | refused |\n| Cost, hidden in the host's view | refused | ACCEPTED, stores {Month=Q2,_Measure=Cost}=7 |\n| Bogus (no such measure) | refused | ACCEPTED, stores {Month=Q2,_Measure=Bogus}=7 |\n| None | refused | ACCEPTED, stores {Month=Q2}=7 |\n\nEach accepted write stores `Month=Q2`, a group name, as a coordinate. That is the improvise-gw18 / improvise-ciw8 shape. The cause is that `cell_position(key)` returns None for a whole key the host does not draw, and \"not drawn\" is read as \"not refused\". The fold is visible in the RECORD half, `{Month=Q2}`, and the old by-record lookup saw it there. No honest client composes these writes, since every client that sees Q2 as a fold sees an Aggregate there. They are hand-rolled or forged, but that also describes b114.\n\n**Repro** (reviewer probe, `ui::host::tests::review_217b::probe_fold_hidden_measure`, not pushed): build the fixture above with `hide_item(\"_Measure\",\"Cost\")`, then `wire`-write {Month=Q2} with measure Cost and assert `refused`. It fails at 6026768c and passes with bc060f67's app.rs.\n\n**Fix shape:** the same view-independent rule improvise-b114 proposes, \"no coordinate of the resolved key names a group rather than an item\", would close this, b114 and the sjdb case together, without asking the host's layout at all. Falling back to `record_position` when the whole key is undrawn also closes this row. But it would refuse Feb x Revenue beside a collapsed Money group, which is a real item cell and arguably legitimate.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:30:17Z","created_by":"agent-review-232-217b","updated_at":"2026-09-23T17:52:52Z","started_at":"2026-09-23T15:39:27Z","closed_at":"2026-09-23T17:52:52Z","close_reason":"Fixed in PR #235, merged as 799a81a after adversarial review at 5c19fdd. Follow-ups: improvise-pr8t, improvise-nacd, improvise-vwc7.","labels":["adversarial-review","pr-217"],"dependencies":[{"issue_id":"improvise-xur5","depends_on_id":"improvise-b114","type":"related","created_at":"2026-09-23T15:30:23Z","created_by":"agent-review-232-217b","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-29m7","title":"The ModelCommand variant count is stated wrongly in three places once #214 and #221 both land (26, not 24 or 25)","description":"Found reviewing PRs #214 (improvise-km71) and #223 against #221 (improvise-fdk1).\n\nEach PR adds one `ModelCommand` variant: #214 adds `ReplaceFormula` and #221 adds `RenameRecordCoordinate`. I materialised gh/main 705c92d9 + #221 + #214 + #223. The enum then has **26** variants, and it builds and passes. Nothing counts the variants in a test, so no guard fails. The prose counts are wrong in every order:\n\n- `crates/improvise-protocol/src/reduce.rs:78` says \"25 variants\" on #214 and \"24 variants\" on #221. So #221 alone is already stale, since it added a variant without updating the count.\n- `context/code/protocol.md` (§2, \"The count is ...\") says \"27 model effects, minus two ... leaves 25\" on #214. On #221 it still says \"29 model effects, minus three ... 26\", the pre-km71 text.\n- Two older claims are stale and become right only by accident after both PRs land:\n - `reduce.rs:525`, \"The exhaustive 26-variant walk is in the host crate\";\n - `context/test-evidence/protocol.md:351`, \"over the 26 `ModelCommand` variants\".\n- `context/repo-map.md` gotchas 3 and 38 say 24. That file is the orchestrator's.\n\n**Fix,** by whichever of #214 and #221 merges second:\n- 26 at `reduce.rs:78`;\n- `context/code/protocol.md` recounted by brace-matching the model effects (`doctrine(derive_then_verify)`);\n- the repo-map lines, by the orchestrator.\n\nA test that pins the count would make this a compile-or-test failure rather than prose.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:15Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9trl","title":"The script remove-formula refusing a missing formula is untested, and it turns a script's exit status from 0 to 1","description":"Found reviewing PR #223 at 1af56ecf (improvise-tej1).\n\n`RemoveFormulaCmd` in `crates/improvise-command/src/command/cmd/effect_cmds.rs` now resolves `\u003ccategory\u003e \u003ctarget\u003e` to a `FormulaId`. When nothing matches, it returns `effect::refuse(\"No formula for T in C\")`. Before this change it was a silent no-op.\n\n**Rival:** replace that `None` arm with `vec![]`. `cargo test --workspace --no-fail-fast` stays fully green, so the new branch has no test.\n\n**The change is user-visible.** `src/main.rs` sets `exit_code = 1` on `last_batch_refused()`. So a headless `remove-formula` of a missing formula now exits 1, where it exited 0. A script that uses the line as an idempotent cleanup now fails.\n\nNo shipped script or example uses `remove-formula`. I grepped examples, docs, web, harness and tests.\n\n`context/code/clients.md` states the refusal. `context/code/cli.md`, which owns what a headless command reports, does not.\n\n**Fix:**\n- a command-level test that a missing formula refuses and names it;\n- a headless test of the exit status;\n- a line in `context/code/cli.md`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:02Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e9ay","title":"command.md's rtud paragraph is false once #229 lands: it says three refuse_and_stop sites are not in the key-path table, and #229 adds all three","description":"Found in adversarial review of PR #229 (head a1d27cc1), composed with #225 (7da905cd, the rtud prose, now on gh/main). Materialising the merge of #229 with gh/main is clean textually, and the suite passes (1873/0). But context/code/command.md's \"Every refusal site has a test that fails when it is reverted\" bullet then says: \"Three sites are not in the key-path table (improvise-rtud). The quit test's 'setup:' assertion holds the refusal of `load` and `import-json absent.json`. `a_formula_that_does_not_parse_...` holds `AddFormula`'s parse arm... The other five import arms are in `every_refusal_site_refuses_its_failure_and_only_its_failure`.\"\n\n#229 adds exactly those three rows to that table (\"load of an absent file\", \"import-json of an absent JSON\", \"a formula that does not parse\"). So after the merge all eight `refuse_and_stop` sites are in the key-path table and the paragraph is false. Also \"The wire table cannot see the refusal half of a refuse_and_stop site\" is still true, but it should now point at `every_failing_model_effect_that_aborts_also_refuses`, which does see it.\n\nFix: in #229, after merging gh/main, rewrite that bullet. All eight sites are in the key-path table (their abort is still `a_failed_load_or_import_does_not_run_the_quit_after_it`), and the effect-level we1a table holds the refusal half the wire table cannot see.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:21Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:21Z","labels":["adversarial-review","pr-229"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lasd","title":"we1a's abort-implies-refuse table is hand-listed: a new Model effect that aborts without refusing leaves the workspace green","description":"Found in adversarial review of PR #229 (head a1d27cc1). `every_failing_model_effect_that_aborts_also_refuses` is a hand-listed table of 13 failing ModelCommands. It is complete TODAY: the Model effects that set `abort_effects` are AddCategory (its own arm), AddFormula's parse arm, LoadModel, and ImportJsonHeadless's six `refuse_and_stop` arms, i.e. 9 sites, and the table has exactly 9 aborting rows, one per site. (Save/SaveAs/ExportCsv abort through `report_file_write` and AbortChain aborts, but all four are ViewOnly and off the wire.)\n\nBut it cannot see a NEW aborting site. Injected: `on_view`'s missing-view branch changed from `app.refuse(why)` to `app.abort_effects = true` without refusing. That makes nine wire geometry variants (SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty naming an absent view) abort without refusing, which is exactly the invariant the test names. Result: `cargo test --workspace` 1858 passed, 0 failed. The PR's context/code/command.md says of the test \"it will fail when that stops being true\", and that is false. Only with the `|| last_batch_aborted()` arm ALSO deleted does one test fail (`a_frame_writing_a_view_its_own_first_command_deleted_is_refused`), so today the arm, not the new test, is what holds.\n\nDerivation instead of a list: observe it in `App::apply_effects`, which already reads each effect's `EffectKind`. After an `EffectKind::Model(_)` effect, `abort_effects \u0026\u0026 !refused` is the violation. It can be a debug_assert (every test that exercises any Model abort then checks it) or be made unrepresentable (a Model abort counts as a refusal). Scoping by kind exempts AbortChain for free, since it is ViewOnly, which answers the PR's first objection to option 2. This is `doctrine(observe, not predict)`, the same move as gotcha 24's frame observation. At minimum, correct the prose to say the table pins the nine listed sites and not the invariant.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:10Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:10Z","labels":["adversarial-review","pr-229"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-5wai","title":"#227's browser +cat commit clear is unpinned: dropping cleared() from create_column leaves the workspace green","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade.\n\n#227 moves each cell-editing commit's buffer clear into the commit: `resolve::cleared`, called from both `commit` and `create_column`. The PR body claims that \"a commit that reopens nothing (the corner, `+cat`) still leaves no text behind\". Only the corner is tested.\n\nInjected fault: I replaced `commands.extend(cleared(\u0026edit_mode));` in `create_column` with `let _ = cleared(\u0026edit_mode);`. The browser's `+cat` commit then leaves the typed category name in the `edit` buffer, while the TUI's head clear empties it. `cargo test -p improvise-command -p improvise-protocol -p improvise-wasm-client -p improvise-worker-server -p improvise-ws-server --no-fail-fast` stays green.\n\nAcceptance: a lockstep test that commits a name at `+cat` and compares both ends' buffers, which must fail under that injection.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-ixxx","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:10Z","created_by":"agent-review-227-230","updated_at":"2026-09-23T15:35:11Z","started_at":"2026-09-23T15:35:11Z","lease_expires_at":"2026-09-23T15:40:11Z","heartbeat_at":"2026-09-23T15:35:11Z","labels":["adversarial-review","pr-227","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-q69m","title":"Two context/code passages still describe tile select opening at tile 0 after improvise-oplp","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f, which does not touch context/code. (1) context/code/command.md, the paragraph beginning 'What the default cursor does cost, stated': it says one cycle key on tile 0 puts _Index on Row, and that the fix 'is the order the tile bar lists categories in'. #231 fixes it with the cursor's opening position (opening_tile) instead, and keeps the order. That paragraph should now say what was done, and why cursor placement beat reordering: click positions and #192's characterization of the offered order both stay unchanged. (2) context/code/clients.md, click Rule 3 ('State the rule this precisely'): 'T enters TileSelect leaving tile_cat_idx at 0, so a click on the leftmost tile ... cycles on the first gesture'. After #231 the tile that cycles on the first click is the first REGULAR tile, not the leftmost. The rule itself is unchanged; its counterexample now names the wrong tile. Gotcha 48 says it plainly: when a fact is stated in more than one place, changing it is a grep.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:37Z","created_by":"agent-review-231","updated_at":"2026-09-23T15:27:32Z","started_at":"2026-09-23T15:27:32Z","lease_expires_at":"2026-09-23T15:32:32Z","heartbeat_at":"2026-09-23T15:27:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bqm0","title":"opening_tile falls back to _Index when a view has no Regular category, so T Enter still empties that grid","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. improvise_protocol::opening_tile is tiles.iter().position(|t| t.regular).unwrap_or(0), and tile 0 is _Index. So in a document with no CategoryKind::Regular category (only _Measure data, or only Label categories beside measures), tile select still opens on _Index, and T Enter reproduces the oplp bug exactly. CONFIRMED with a scratch test at 2383c37f: Workbook::new with one _Measure item Revenue=5, App::new, handle_key T then Enter: layout went rows=1 cols=1 -\u003e rows=0 cols=1, _Index None -\u003e Row. Proposed fix: the fallback should be the first tile that is neither _Index nor _Dim (in practice _Measure), not index 0. A bool cannot express that without the client sniffing names, so carry the CategoryKind (already Serialize, and in improvise-core, which protocol depends on) in TileEntry in place of regular: bool, and let opening_tile rank Regular, then VirtualMeasure/Label, then the rest. _Measure should NOT be the primary opening: it is on an axis in every shipped document, and T Enter reads as confirm on the user's own dimension. It is the right fallback, though. Guard: a lockstep case on a no-regular document asserting that _Index stays off Row after T Enter.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:21Z","created_by":"agent-review-231","updated_at":"2026-09-23T15:27:33Z","started_at":"2026-09-23T15:27:33Z","lease_expires_at":"2026-09-23T15:32:33Z","heartbeat_at":"2026-09-23T15:27:33Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-lqs0","title":"bd-audit-ids: a file grep cannot read passes the audit (xargs 123 read as no-match); two exit-code mismatches","description":"Found in the adversarial review of PR #226 (improvise-52eq) at head 168a28d2.\n\nscripts/bd-audit-ids treats xargs exit 123 as \"one grep batch matched nothing\". But xargs also returns 123 when grep exits 2 on a read error. So a file the scan could not read makes the audit pass, and any dead id in that file is missed. The usage text promises exit 4 when \"the file scan itself failed\".\n\nReproduced in a throwaway repo with two tracked files: a.md cites a known id, b.md cites a dead id.\n- Control, both readable: exit 1, names the dead id.\n- b.md chmod 000: prints `grep: context/b.md: Permission denied`, then \"all 1 cited ids resolve\", exit 0.\n- b.md deleted from the worktree but still tracked (ls-files --cached lists it): prints \"No such file or directory\", then exit 0. This case is arguably right: the file's ids are leaving the tree. The fix must decide it on purpose, not by accident.\n\nTwo smaller exit-code mismatches with the stated contract. Both fail loudly, not silently:\n- Run outside a git repo: `cd: null directory`, exit 1. Exit 1 is the \"dead id\" code; the contract says 4.\n- bd prints a JSON array of objects with no `id` field: jq yields `null`, so `known` is non-empty. Every cited id is then reported dead, exit 1. It should be exit 3, \"bd did not answer\".\n\nThis is not a regression. The old recipe ignored grep errors the same way. It is the same shape 52eq fixed on the bd side: a failure that reads as a clean audit.\n\nSuggested fix: send grep's stderr to a file, and exit 4 if it names anything other than an intended skip. Also check `git rev-parse` before `cd`, and filter `null` out of the known ids. Each case needs a check-the-checker case in scripts/bd-audit-ids-test.sh.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:30Z","created_by":"agent-review-211-226","updated_at":"2026-09-23T16:00:02Z","started_at":"2026-09-23T16:00:02Z","lease_expires_at":"2026-09-23T16:05:02Z","heartbeat_at":"2026-09-23T16:00:02Z","labels":["adversarial-review","pr-226","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-zs2r","title":"A manifest-only change to a path dependency stales harness/opfs-contract/Cargo.lock, and no fast gate sees it","description":"Found by the adversarial review of PR #224 (improvise-tt22), head e62a289c.\n\nPR #224 changed only workspace manifests. It removed indexmap's serde feature and improvise-formula's serde dependency, and updated the root Cargo.lock. harness/opfs-contract is its own workspace, with its own Cargo.lock, and it depends on improvise-io by path. That lock went stale: `cargo metadata` there drops \"serde\" from improvise-formula and \"serde\"/\"serde_core\" from indexmap. So CI \"Build all bundles\" failed at e62a289c on `--locked`, and \"Browser harness\" was skipped.\n\nThe agent-rules gate list did not send the author there. It runs harness/build.sh only for a change to web/, harness/ or the page's wasm surface. A manifest change to any crate opfs-contract reaches (improvise-io, improvise-core, improvise-formula) stales the lock too. Every host gate stayed green.\n\nTwo cheap closes:\n(a) agent-rules.md: extend the build/browser bullet to \"or any Cargo.toml a crate in harness/opfs-contract's dependency graph reads\".\n(b) Better: have the host `test` job run `cargo metadata --locked --format-version 1 \u003e/dev/null` in harness/opfs-contract. It takes seconds and fails in the fast job rather than the bundle job.\n\nThe message itself is improvise-lnsh.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:04Z","created_by":"agent-review-224","updated_at":"2026-09-23T15:59:47Z","started_at":"2026-09-23T15:59:47Z","lease_expires_at":"2026-09-23T16:04:47Z","heartbeat_at":"2026-09-23T15:59:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7fl8","title":"pr-reviews-test: timeout case asserts \u003e=5 polls where the fake clock makes it exactly 6; a loop stopping one tick early passes","description":"Found reviewing PR #218 at ebf3dd2b (improvise-7epn). The new case \"--wait keeps polling until its deadline\" asserts at least 5 polls. On the one-tick-per-read fake clock the correct loop polls exactly 6 times for --wait 5 (1 initial + 5 re-polls; deadline 1006, post-poll checks read 1002..1006). Two rivals pass the whole suite at 25/25, both injected and run:\n- a loop capped at 5 total polls regardless of the deadline (counter after the deadline check, break after 4 re-polls);\n- an off-by-one deadline, deadline=$(( $(date +%s) + WAIT - 1 )), which stops one second short (5 polls).\nThe evidence entry in context/test-evidence/tooling.md says the poll count tells \"a loop that stopped early from one that reached the deadline\"; it does so only for a loop that stops 2+ ticks early. Fix: assert polls = 6 (the fake clock makes the count exact, WAIT+1), which also catches an over-polling -le. Still open from 7epn's own description: deleting the deadline check makes the timeout case hang rather than FAIL; a bounded run (a poll ceiling in the fixture, or timeout) would turn that into a failure.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:58Z","created_by":"agent-review-218","updated_at":"2026-09-23T15:37:47Z","started_at":"2026-09-23T15:37:47Z","lease_expires_at":"2026-09-23T15:42:47Z","heartbeat_at":"2026-09-23T15:37:47Z","labels":["adversarial-review-pr-218"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cp76","title":"vndi's swap test has no control: an EditTarget that captures generation 0 always passes the suite","description":"Found in adversarial review of PR #219 (improvise-vndi) at head 5690903d.\n\nan_edit_opened_before_a_document_swap_is_refused_in_the_new_document pins only the REFUSE direction, and its fixture opens the editor at document_generation 0. Injected rival (run, then reverted): socket.rs::edit_target_at captures `document_generation = 0` instead of the basis's. The whole workspace stays green under it (cargo test --workspace --no-fail-fast, no FAILED). That rival refuses every inline edit opened after ANY LoadModel in a session with DocumentReplaced, forever, and nothing sees it: the one test that sets a nonzero generation asserts a refusal, which the rival also produces. Vacuity shape: a fixture that is right by accident (captured value 0 = the default).\n\nFix: a control in the same test or beside it — after the swap has been folded, open a FRESH editor (generation 1) on the same cell and commit; assert it is accepted and the value lands. That separates \"captures the generation at open\" from \"captures a constant\" and from \"refuses after any swap\". The primary rival (claim_as_read ignoring its argument) still fails the existing test, as the evidence entry says.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:28Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:28Z","labels":["adversarial-review","pr-219","wasm-client"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-29m7","title":"The ModelCommand variant count is stated wrongly in three places once #214 and #221 both land (26, not 24 or 25)","description":"Found reviewing PRs #214 (improvise-km71) and #223 against #221 (improvise-fdk1).\n\nEach PR adds one `ModelCommand` variant: #214 adds `ReplaceFormula` and #221 adds `RenameRecordCoordinate`. I materialised gh/main 705c92d9 + #221 + #214 + #223. The enum then has **26** variants, and it builds and passes. Nothing counts the variants in a test, so no guard fails. The prose counts are wrong in every order:\n\n- `crates/improvise-protocol/src/reduce.rs:78` says \"25 variants\" on #214 and \"24 variants\" on #221. So #221 alone is already stale, since it added a variant without updating the count.\n- `context/code/protocol.md` (§2, \"The count is ...\") says \"27 model effects, minus two ... leaves 25\" on #214. On #221 it still says \"29 model effects, minus three ... 26\", the pre-km71 text.\n- Two older claims are stale and become right only by accident after both PRs land:\n - `reduce.rs:525`, \"The exhaustive 26-variant walk is in the host crate\";\n - `context/test-evidence/protocol.md:351`, \"over the 26 `ModelCommand` variants\".\n- `context/repo-map.md` gotchas 3 and 38 say 24. That file is the orchestrator's.\n\n**Fix,** by whichever of #214 and #221 merges second:\n- 26 at `reduce.rs:78`;\n- `context/code/protocol.md` recounted by brace-matching the model effects (`doctrine(derive_then_verify)`);\n- the repo-map lines, by the orchestrator.\n\nA test that pins the count would make this a compile-or-test failure rather than prose.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:15Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:15Z","dependencies":[{"issue_id":"improvise-29m7","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:01Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9trl","title":"The script remove-formula refusing a missing formula is untested, and it turns a script's exit status from 0 to 1","description":"Found reviewing PR #223 at 1af56ecf (improvise-tej1).\n\n`RemoveFormulaCmd` in `crates/improvise-command/src/command/cmd/effect_cmds.rs` now resolves `\u003ccategory\u003e \u003ctarget\u003e` to a `FormulaId`. When nothing matches, it returns `effect::refuse(\"No formula for T in C\")`. Before this change it was a silent no-op.\n\n**Rival:** replace that `None` arm with `vec![]`. `cargo test --workspace --no-fail-fast` stays fully green, so the new branch has no test.\n\n**The change is user-visible.** `src/main.rs` sets `exit_code = 1` on `last_batch_refused()`. So a headless `remove-formula` of a missing formula now exits 1, where it exited 0. A script that uses the line as an idempotent cleanup now fails.\n\nNo shipped script or example uses `remove-formula`. I grepped examples, docs, web, harness and tests.\n\n`context/code/clients.md` states the refusal. `context/code/cli.md`, which owns what a headless command reports, does not.\n\n**Fix:**\n- a command-level test that a missing formula refuses and names it;\n- a headless test of the exit status;\n- a line in `context/code/cli.md`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:29:02Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T15:29:02Z","dependencies":[{"issue_id":"improvise-9trl","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:14Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e9ay","title":"command.md's rtud paragraph is false once #229 lands: it says three refuse_and_stop sites are not in the key-path table, and #229 adds all three","description":"Found in adversarial review of PR #229 (head a1d27cc1), composed with #225 (7da905cd, the rtud prose, now on gh/main). Materialising the merge of #229 with gh/main is clean textually, and the suite passes (1873/0). But context/code/command.md's \"Every refusal site has a test that fails when it is reverted\" bullet then says: \"Three sites are not in the key-path table (improvise-rtud). The quit test's 'setup:' assertion holds the refusal of `load` and `import-json absent.json`. `a_formula_that_does_not_parse_...` holds `AddFormula`'s parse arm... The other five import arms are in `every_refusal_site_refuses_its_failure_and_only_its_failure`.\"\n\n#229 adds exactly those three rows to that table (\"load of an absent file\", \"import-json of an absent JSON\", \"a formula that does not parse\"). So after the merge all eight `refuse_and_stop` sites are in the key-path table and the paragraph is false. Also \"The wire table cannot see the refusal half of a refuse_and_stop site\" is still true, but it should now point at `every_failing_model_effect_that_aborts_also_refuses`, which does see it.\n\nFix: in #229, after merging gh/main, rewrite that bullet. All eight sites are in the key-path table (their abort is still `a_failed_load_or_import_does_not_run_the_quit_after_it`), and the effect-level we1a table holds the refusal half the wire table cannot see.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:21Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:21Z","labels":["adversarial-review","pr-229"],"dependencies":[{"issue_id":"improvise-e9ay","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:12Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lasd","title":"we1a's abort-implies-refuse table is hand-listed: a new Model effect that aborts without refusing leaves the workspace green","description":"Found in adversarial review of PR #229 (head a1d27cc1). `every_failing_model_effect_that_aborts_also_refuses` is a hand-listed table of 13 failing ModelCommands. It is complete TODAY: the Model effects that set `abort_effects` are AddCategory (its own arm), AddFormula's parse arm, LoadModel, and ImportJsonHeadless's six `refuse_and_stop` arms, i.e. 9 sites, and the table has exactly 9 aborting rows, one per site. (Save/SaveAs/ExportCsv abort through `report_file_write` and AbortChain aborts, but all four are ViewOnly and off the wire.)\n\nBut it cannot see a NEW aborting site. Injected: `on_view`'s missing-view branch changed from `app.refuse(why)` to `app.abort_effects = true` without refusing. That makes nine wire geometry variants (SetAxis, SetPageSelection, ToggleGroup, HideItem, ShowItem, TransposeAxes, CycleAxis, SetNumberFormat, TogglePruneEmpty naming an absent view) abort without refusing, which is exactly the invariant the test names. Result: `cargo test --workspace` 1858 passed, 0 failed. The PR's context/code/command.md says of the test \"it will fail when that stops being true\", and that is false. Only with the `|| last_batch_aborted()` arm ALSO deleted does one test fail (`a_frame_writing_a_view_its_own_first_command_deleted_is_refused`), so today the arm, not the new test, is what holds.\n\nDerivation instead of a list: observe it in `App::apply_effects`, which already reads each effect's `EffectKind`. After an `EffectKind::Model(_)` effect, `abort_effects \u0026\u0026 !refused` is the violation. It can be a debug_assert (every test that exercises any Model abort then checks it) or be made unrepresentable (a Model abort counts as a refusal). Scoping by kind exempts AbortChain for free, since it is ViewOnly, which answers the PR's first objection to option 2. This is `doctrine(observe, not predict)`, the same move as gotcha 24's frame observation. At minimum, correct the prose to say the table pins the nine listed sites and not the invariant.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:25:10Z","created_by":"agent-review-228-229","updated_at":"2026-09-23T15:25:10Z","labels":["adversarial-review","pr-229"],"dependencies":[{"issue_id":"improvise-lasd","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:12Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-5wai","title":"#227's browser +cat commit clear is unpinned: dropping cleared() from create_column leaves the workspace green","description":"Found by the adversarial review of PR #227 (improvise-fy9o) at head 8ee5fade.\n\n#227 moves each cell-editing commit's buffer clear into the commit: `resolve::cleared`, called from both `commit` and `create_column`. The PR body claims that \"a commit that reopens nothing (the corner, `+cat`) still leaves no text behind\". Only the corner is tested.\n\nInjected fault: I replaced `commands.extend(cleared(\u0026edit_mode));` in `create_column` with `let _ = cleared(\u0026edit_mode);`. The browser's `+cat` commit then leaves the typed category name in the `edit` buffer, while the TUI's head clear empties it. `cargo test -p improvise-command -p improvise-protocol -p improvise-wasm-client -p improvise-worker-server -p improvise-ws-server --no-fail-fast` stays green.\n\nAcceptance: a lockstep test that commits a name at `+cat` and compares both ends' buffers, which must fail under that injection.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:24:10Z","created_by":"agent-review-227-230","updated_at":"2026-09-25T01:00:00Z","closed_at":"2026-09-25T01:00:00Z","close_reason":"Landed in 85c483bd on gh/main (pin the +cat clear)","labels":["adversarial-review","pr-227","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-q69m","title":"Two context/code passages still describe tile select opening at tile 0 after improvise-oplp","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f, which does not touch context/code. (1) context/code/command.md, the paragraph beginning 'What the default cursor does cost, stated': it says one cycle key on tile 0 puts _Index on Row, and that the fix 'is the order the tile bar lists categories in'. #231 fixes it with the cursor's opening position (opening_tile) instead, and keeps the order. That paragraph should now say what was done, and why cursor placement beat reordering: click positions and #192's characterization of the offered order both stay unchanged. (2) context/code/clients.md, click Rule 3 ('State the rule this precisely'): 'T enters TileSelect leaving tile_cat_idx at 0, so a click on the leftmost tile ... cycles on the first gesture'. After #231 the tile that cycles on the first click is the first REGULAR tile, not the leftmost. The rule itself is unchanged; its counterexample now names the wrong tile. Gotcha 48 says it plainly: when a fact is stated in more than one place, changing it is a grep.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:37Z","created_by":"agent-review-231","updated_at":"2026-09-25T01:00:00Z","closed_at":"2026-09-25T01:00:00Z","close_reason":"Landed in #242 (66bd3fcb on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bqm0","title":"opening_tile falls back to _Index when a view has no Regular category, so T Enter still empties that grid","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. improvise_protocol::opening_tile is tiles.iter().position(|t| t.regular).unwrap_or(0), and tile 0 is _Index. So in a document with no CategoryKind::Regular category (only _Measure data, or only Label categories beside measures), tile select still opens on _Index, and T Enter reproduces the oplp bug exactly. CONFIRMED with a scratch test at 2383c37f: Workbook::new with one _Measure item Revenue=5, App::new, handle_key T then Enter: layout went rows=1 cols=1 -\u003e rows=0 cols=1, _Index None -\u003e Row. Proposed fix: the fallback should be the first tile that is neither _Index nor _Dim (in practice _Measure), not index 0. A bool cannot express that without the client sniffing names, so carry the CategoryKind (already Serialize, and in improvise-core, which protocol depends on) in TileEntry in place of regular: bool, and let opening_tile rank Regular, then VirtualMeasure/Label, then the rest. _Measure should NOT be the primary opening: it is on an axis in every shipped document, and T Enter reads as confirm on the user's own dimension. It is the right fallback, though. Guard: a lockstep case on a no-regular document asserting that _Index stays off Row after T Enter.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:21Z","created_by":"agent-review-231","updated_at":"2026-09-25T01:00:00Z","closed_at":"2026-09-25T01:00:00Z","close_reason":"Landed in #242 (66bd3fcb on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-lqs0","title":"bd-audit-ids: a file grep cannot read passes the audit (xargs 123 read as no-match); two exit-code mismatches","description":"Found in the adversarial review of PR #226 (improvise-52eq) at head 168a28d2.\n\nscripts/bd-audit-ids treats xargs exit 123 as \"one grep batch matched nothing\". But xargs also returns 123 when grep exits 2 on a read error. So a file the scan could not read makes the audit pass, and any dead id in that file is missed. The usage text promises exit 4 when \"the file scan itself failed\".\n\nReproduced in a throwaway repo with two tracked files: a.md cites a known id, b.md cites a dead id.\n- Control, both readable: exit 1, names the dead id.\n- b.md chmod 000: prints `grep: context/b.md: Permission denied`, then \"all 1 cited ids resolve\", exit 0.\n- b.md deleted from the worktree but still tracked (ls-files --cached lists it): prints \"No such file or directory\", then exit 0. This case is arguably right: the file's ids are leaving the tree. The fix must decide it on purpose, not by accident.\n\nTwo smaller exit-code mismatches with the stated contract. Both fail loudly, not silently:\n- Run outside a git repo: `cd: null directory`, exit 1. Exit 1 is the \"dead id\" code; the contract says 4.\n- bd prints a JSON array of objects with no `id` field: jq yields `null`, so `known` is non-empty. Every cited id is then reported dead, exit 1. It should be exit 3, \"bd did not answer\".\n\nThis is not a regression. The old recipe ignored grep errors the same way. It is the same shape 52eq fixed on the bd side: a failure that reads as a clean audit.\n\nSuggested fix: send grep's stderr to a file, and exit 4 if it names anything other than an intended skip. Also check `git rev-parse` before `cd`, and filter `null` out of the known ids. Each case needs a check-the-checker case in scripts/bd-audit-ids-test.sh.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:30Z","created_by":"agent-review-211-226","updated_at":"2026-09-24T21:45:23Z","started_at":"2026-09-23T16:00:02Z","closed_at":"2026-09-24T21:45:23Z","close_reason":"Landed in main via #244 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-226","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-zs2r","title":"A manifest-only change to a path dependency stales harness/opfs-contract/Cargo.lock, and no fast gate sees it","description":"Found by the adversarial review of PR #224 (improvise-tt22), head e62a289c.\n\nPR #224 changed only workspace manifests. It removed indexmap's serde feature and improvise-formula's serde dependency, and updated the root Cargo.lock. harness/opfs-contract is its own workspace, with its own Cargo.lock, and it depends on improvise-io by path. That lock went stale: `cargo metadata` there drops \"serde\" from improvise-formula and \"serde\"/\"serde_core\" from indexmap. So CI \"Build all bundles\" failed at e62a289c on `--locked`, and \"Browser harness\" was skipped.\n\nThe agent-rules gate list did not send the author there. It runs harness/build.sh only for a change to web/, harness/ or the page's wasm surface. A manifest change to any crate opfs-contract reaches (improvise-io, improvise-core, improvise-formula) stales the lock too. Every host gate stayed green.\n\nTwo cheap closes:\n(a) agent-rules.md: extend the build/browser bullet to \"or any Cargo.toml a crate in harness/opfs-contract's dependency graph reads\".\n(b) Better: have the host `test` job run `cargo metadata --locked --format-version 1 \u003e/dev/null` in harness/opfs-contract. It takes seconds and fails in the fast job rather than the bundle job.\n\nThe message itself is improvise-lnsh.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:08:04Z","created_by":"agent-review-224","updated_at":"2026-09-24T21:45:26Z","started_at":"2026-09-23T15:59:47Z","closed_at":"2026-09-24T21:45:26Z","close_reason":"Landed in main via #245 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7fl8","title":"pr-reviews-test: timeout case asserts \u003e=5 polls where the fake clock makes it exactly 6; a loop stopping one tick early passes","description":"Found reviewing PR #218 at ebf3dd2b (improvise-7epn). The new case \"--wait keeps polling until its deadline\" asserts at least 5 polls. On the one-tick-per-read fake clock the correct loop polls exactly 6 times for --wait 5 (1 initial + 5 re-polls; deadline 1006, post-poll checks read 1002..1006). Two rivals pass the whole suite at 25/25, both injected and run:\n- a loop capped at 5 total polls regardless of the deadline (counter after the deadline check, break after 4 re-polls);\n- an off-by-one deadline, deadline=$(( $(date +%s) + WAIT - 1 )), which stops one second short (5 polls).\nThe evidence entry in context/test-evidence/tooling.md says the poll count tells \"a loop that stopped early from one that reached the deadline\"; it does so only for a loop that stops 2+ ticks early. Fix: assert polls = 6 (the fake clock makes the count exact, WAIT+1), which also catches an over-polling -le. Still open from 7epn's own description: deleting the deadline check makes the timeout case hang rather than FAIL; a bounded run (a poll ceiling in the fixture, or timeout) would turn that into a failure.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:06:58Z","created_by":"agent-review-218","updated_at":"2026-09-24T21:45:03Z","started_at":"2026-09-23T15:37:47Z","closed_at":"2026-09-24T21:45:03Z","close_reason":"Landed in main via #237 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-218"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-cp76","title":"vndi's swap test has no control: an EditTarget that captures generation 0 always passes the suite","description":"Found in adversarial review of PR #219 (improvise-vndi) at head 5690903d.\n\nan_edit_opened_before_a_document_swap_is_refused_in_the_new_document pins only the REFUSE direction, and its fixture opens the editor at document_generation 0. Injected rival (run, then reverted): socket.rs::edit_target_at captures `document_generation = 0` instead of the basis's. The whole workspace stays green under it (cargo test --workspace --no-fail-fast, no FAILED). That rival refuses every inline edit opened after ANY LoadModel in a session with DocumentReplaced, forever, and nothing sees it: the one test that sets a nonzero generation asserts a refusal, which the rival also produces. Vacuity shape: a fixture that is right by accident (captured value 0 = the default).\n\nFix: a control in the same test or beside it — after the swap has been folded, open a FRESH editor (generation 1) on the same cell and commit; assert it is accepted and the value lands. That separates \"captures the generation at open\" from \"captures a constant\" and from \"refuses after any swap\". The primary rival (claim_as_read ignoring its argument) still fails the existing test, as the evidence entry says.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:28Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:28Z","labels":["adversarial-review","pr-219","wasm-client"],"dependencies":[{"issue_id":"improvise-cp76","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cq3d","title":"A standing status_msg hides the derived key hint in every TUI mode: nothing clears it","description":"Found in adversarial review of PR #220 (improvise-17ak) at head 5e37973f, while checking that PR's reason for keeping the help footer.\n\nCONFIRMED with a scratch integration test (not committed): App::new over a one-category workbook, status_msg = \"Saved to x.improv\", then keys j k l h ? (enters Help), l, q (back to Normal). status_msg is still \"Saved to x.improv\" after every key and both mode changes.\n\nsrc/draw.rs::draw_status prints status_msg INSTEAD OF app.hint_text() whenever the message is non-empty, and no production code clears it: the only `status_msg.clear()` / `= String::new()` sites are in tests (improvise-command ui/app.rs ~2303, ui/effect.rs ~2313). A message is only ever replaced by another message. So after the first \"Saved\", \"Loaded\", or error of a session, the derived key hint (hint_line / hint_text, the table rr8n made the one true key table on screen) is never drawn again in ANY mode, until something else writes a message.\n\nThis is why PR #220 had to keep a key table inside the help popup footer: the status row that rr8n's rule would otherwise rely on is hidden by a stale message. Fix shape is a design question: clear status_msg on mode change or on the next keypress (vim-style), or compose message and hint on one line with gotcha 50's Keeps. Test must assert the hint is drawn after a message, in a mode other than the one the message was written in.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:02Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T17:52:51Z","started_at":"2026-09-23T15:14:38Z","closed_at":"2026-09-23T17:52:51Z","close_reason":"Fixed in PR #233, merged as b8a4849 after adversarial review at d9ea594. Follow-up: improvise-su4u.","labels":["adversarial-review","pr-220","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qlrh","title":"every_shipped_document_loads does not walk harness/fixtures","description":"Raised by the author of PR #224 (improvise-tt22) and confirmed by its adversarial review at head e62a289c.\n\ntests/shipped_documents_load.rs::every_shipped_document_loads walks only examples/ and web/. harness/fixtures/grouped.improv and harness/fixtures/tall.improv are not walked. Both start with the v2025-04-09 version line and load today.\n\nTheir only loader is the Playwright browser job. harness/build.sh copies them beside the demo, and grouped.spec.js, narrow.spec.js and contract.spec.js assert labels the fixtures carry. So a format change that stops them loading is caught, but only by the slow browser job, and only as a missing-label failure. A missing seed degrades to an EMPTY model (web/worker.js), not a load error, so that failure does not name the cause. `cargo test` never sees it.\n\nFix: add \"harness/fixtures\" to the directory list in shipped_documents(). Keep the precondition count honest: it asserts \u003e= 2 today, so raise it or assert per directory. Otherwise a walk that silently lost one directory would still pass.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:26Z","created_by":"agent-review-224","updated_at":"2026-09-23T15:42:06Z","started_at":"2026-09-23T15:24:00Z","closed_at":"2026-09-23T15:42:06Z","close_reason":"Fixed in PR #234, merged after adversarial review at 91c80cb. Follow-up: improvise-c0aj.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-o9q7","title":"A drill commit refused for a removed category reports a later row's blank-coordinate refusal instead, so the user is never told every staged edit was discarded","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. merged() keeps ALL rows' refusals when any row is unwritable, and effects() applies them in row order, so status_msg shows the LAST. Probe: drill of row 0 {Type=Food,Month=Jan} and row 1 {Month=Jan} (no Type); remove_category(Type); stage row 0 Cost=5, row 1 Month='' and row 1 Cost=999; ApplyAndClearDrill. Status: 'Record coordinates cannot be empty'. Store: untouched. The user reads that their blank was refused, and command.md says a blank coordinate is 'refused alone, and the row's other edits still land', so they expect row 1's Cost=999 and row 0's Cost=5 to have landed. Neither did, and 'Unknown category Type' was overwritten. Two further gaps in the message even without the mask: it says WHY, never WHAT was lost (every staged edit in the drill, including writable rows'), and the edits are not recoverable (drill_state is taken; re-entering gives a fresh snapshot). Fix candidates: when unwritable, keep only the unwritable refusals (the blank ones are moot, nothing is written), and word it as a discard, e.g. \"Drill edits discarded: unknown category 'Type'\".","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:10Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:10Z","labels":["adversarial-review","pr-222"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-6r2y","title":"RecordRewrite::of's unwritable check over RENAMED categories is unpinned: dropping .chain(renames.keys()) leaves the suite green","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. The check is documented (context/code/command.md, and the PR body) as covering 'both its coordinates and its renames'. Injected rival: comment out .chain(renames.keys().copied()) in RecordRewrite::of. improvise-command lib: 607 passed, 0 of the PR's tests failed. The rename test (a_drill_rename_into_a_removed_category_is_refused_and_writes_nothing) is vacuous for that half because row 1's COORDINATES already name Type, so the coords half refuses first. The half is only reachable on a SPARSE row (one with no Type coordinate) that stages a Type rename after Type is removed. Discriminating probe: a drill of one untyped record {Month=Jan,_Measure=Cost}=17; remove_category(Type); stage Category(Type)=Drink; ApplyAndClearDrill; assert store == {-/Jan/Cost = 17} and refused. Passes at 8490f34f, fails under the rival (writes Drink/Jan/Cost, AddItem refusing beside it: the qz9p shape again). Fix: add that test in #222.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:06Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:06Z","labels":["adversarial-review","pr-222"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-dak5","title":"Nothing checks that every checkers.sh suite runs in CI: ci.yml names the suites by hand","description":"Found by agent-mergetrain on improvise-52eq (PR #226). The CI step runs 'checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces', an explicit list. A new scripts/*-test.sh is picked up by checkers.sh locally but never runs in CI unless someone remembers to add it. Derive the CI list from 'checkers.sh --list', or add a check (in check-ci-graph.py or similar) that every suite checkers.sh knows appears in ci.yml. Plant a fake suite and show the check fails.","status":"in_progress","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:01:41Z","created_by":"orchestrator","updated_at":"2026-09-23T15:02:20Z","started_at":"2026-09-23T15:02:20Z","lease_expires_at":"2026-09-23T15:07:20Z","heartbeat_at":"2026-09-23T15:02:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mv1d","title":"Browser help overlay overflows horizontally at phone width; #215's sticky footer scrolls away with it","description":"Found by the adversarial review of PR #215 at head a8737f3f. Measured in Chromium through the harness on the merge of a8737f3f into gh/main (52c6a053).\n\n**Part one predates #215.** At a 320px viewport, `#help` overflows horizontally on every page:\n\n- Welcome: scrollWidth 692 against clientWidth 254.\n- Navigation: scrollWidth 510 against clientWidth 254. The first `dd` starts at x=329, past the right edge of the 320px viewport.\n\nThe same numbers come out with gh/main's web/app.js and style.css, so #215 did not cause this. On a phone, every key description sits off-screen to the right, and the only way to read them is to scroll the overlay sideways.\n\n**Part two is new in #215.** `.help-footer` (improvise-1c5m) is `position: sticky; bottom: -1.25rem`. It sticks to the bottom only. So when the user scrolls the overlay sideways to read those descriptions, the footer scrolls away with the content. With scrollLeft at its maximum on Navigation, the footer's box is at x = -199..7, and `elementFromPoint` does not find it. This affects only 1c5m's narrow-width case, and only while the overlay is scrolled sideways. At scrollLeft 0 the footer is visible and topmost, at 320x568, 320x300 and 320x200 alike.\n\n**Suggested fix.** Remove the horizontal overflow at phone width, for example by collapsing the help-body grid to one column below a breakpoint. That fixes both parts. Adding `left: 0` to the footer alone fixes only part two.\n\n**How to reproduce.** Add a Playwright case at 320x568 that opens help, presses `l`, and sets `#help.scrollLeft = scrollWidth`. Then check `elementFromPoint` on `.help-footer`, and check the first `dd`'s bounding rect against `innerWidth`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T14:57:36Z","created_by":"agent-review-215","updated_at":"2026-09-23T14:57:36Z","labels":["adversarial-review","browser","help","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-o9q7","title":"A drill commit refused for a removed category reports a later row's blank-coordinate refusal instead, so the user is never told every staged edit was discarded","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. merged() keeps ALL rows' refusals when any row is unwritable, and effects() applies them in row order, so status_msg shows the LAST. Probe: drill of row 0 {Type=Food,Month=Jan} and row 1 {Month=Jan} (no Type); remove_category(Type); stage row 0 Cost=5, row 1 Month='' and row 1 Cost=999; ApplyAndClearDrill. Status: 'Record coordinates cannot be empty'. Store: untouched. The user reads that their blank was refused, and command.md says a blank coordinate is 'refused alone, and the row's other edits still land', so they expect row 1's Cost=999 and row 0's Cost=5 to have landed. Neither did, and 'Unknown category Type' was overwritten. Two further gaps in the message even without the mask: it says WHY, never WHAT was lost (every staged edit in the drill, including writable rows'), and the edits are not recoverable (drill_state is taken; re-entering gives a fresh snapshot). Fix candidates: when unwritable, keep only the unwritable refusals (the blank ones are moot, nothing is written), and word it as a discard, e.g. \"Drill edits discarded: unknown category 'Type'\".","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:10Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:10Z","labels":["adversarial-review","pr-222"],"dependencies":[{"issue_id":"improvise-o9q7","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-6r2y","title":"RecordRewrite::of's unwritable check over RENAMED categories is unpinned: dropping .chain(renames.keys()) leaves the suite green","description":"Found reviewing PR #222 (improvise-qz9p) at 8490f34f. The check is documented (context/code/command.md, and the PR body) as covering 'both its coordinates and its renames'. Injected rival: comment out .chain(renames.keys().copied()) in RecordRewrite::of. improvise-command lib: 607 passed, 0 of the PR's tests failed. The rename test (a_drill_rename_into_a_removed_category_is_refused_and_writes_nothing) is vacuous for that half because row 1's COORDINATES already name Type, so the coords half refuses first. The half is only reachable on a SPARSE row (one with no Type coordinate) that stages a Type rename after Type is removed. Discriminating probe: a drill of one untyped record {Month=Jan,_Measure=Cost}=17; remove_category(Type); stage Category(Type)=Drink; ApplyAndClearDrill; assert store == {-/Jan/Cost = 17} and refused. Passes at 8490f34f, fails under the rival (writes Drink/Jan/Cost, AddItem refusing beside it: the qz9p shape again). Fix: add that test in #222.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:04:06Z","created_by":"agent-review-222","updated_at":"2026-09-23T15:04:06Z","labels":["adversarial-review","pr-222"],"dependencies":[{"issue_id":"improvise-6r2y","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:23Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dak5","title":"Nothing checks that every checkers.sh suite runs in CI: ci.yml names the suites by hand","description":"Found by agent-mergetrain on improvise-52eq (PR #226). The CI step runs 'checkers.sh pr-watch release-preflight pr-reviews fmt-workspaces', an explicit list. A new scripts/*-test.sh is picked up by checkers.sh locally but never runs in CI unless someone remembers to add it. Derive the CI list from 'checkers.sh --list', or add a check (in check-ci-graph.py or similar) that every suite checkers.sh knows appears in ci.yml. Plant a fake suite and show the check fails.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T15:01:41Z","created_by":"orchestrator","updated_at":"2026-09-24T21:44:47Z","started_at":"2026-09-23T15:02:20Z","closed_at":"2026-09-24T21:44:47Z","close_reason":"Landed in main via #228 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mv1d","title":"Browser help overlay overflows horizontally at phone width; #215's sticky footer scrolls away with it","description":"Found by the adversarial review of PR #215 at head a8737f3f. Measured in Chromium through the harness on the merge of a8737f3f into gh/main (52c6a053).\n\n**Part one predates #215.** At a 320px viewport, `#help` overflows horizontally on every page:\n\n- Welcome: scrollWidth 692 against clientWidth 254.\n- Navigation: scrollWidth 510 against clientWidth 254. The first `dd` starts at x=329, past the right edge of the 320px viewport.\n\nThe same numbers come out with gh/main's web/app.js and style.css, so #215 did not cause this. On a phone, every key description sits off-screen to the right, and the only way to read them is to scroll the overlay sideways.\n\n**Part two is new in #215.** `.help-footer` (improvise-1c5m) is `position: sticky; bottom: -1.25rem`. It sticks to the bottom only. So when the user scrolls the overlay sideways to read those descriptions, the footer scrolls away with the content. With scrollLeft at its maximum on Navigation, the footer's box is at x = -199..7, and `elementFromPoint` does not find it. This affects only 1c5m's narrow-width case, and only while the overlay is scrolled sideways. At scrollLeft 0 the footer is visible and topmost, at 320x568, 320x300 and 320x200 alike.\n\n**Suggested fix.** Remove the horizontal overflow at phone width, for example by collapsing the help-body grid to one column below a breakpoint. That fixes both parts. Adding `left: 0` to the footer alone fixes only part two.\n\n**How to reproduce.** Add a Playwright case at 320x568 that opens help, presses `l`, and sets `#help.scrollLeft = scrollWidth`. Then check `elementFromPoint` on `.help-footer`, and check the first `dd`'s bounding rect against `innerWidth`.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T14:57:36Z","created_by":"agent-review-215","updated_at":"2026-09-23T14:57:36Z","labels":["adversarial-review","browser","help","ux"],"dependencies":[{"issue_id":"improvise-mv1d","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b114","title":"Wire SetCell to a collapsed _Measure-group fold is accepted and stores a non-item _Measure coordinate (#217's stored arm skips the projection)","description":"Found in adversarial review of PR #217 (improvise-ciw8), head bc060f67. Pre-existing, and NOT closed by #217, although it is the same class #217 claims to close.\n\nWith `_Measure` on Column and Revenue,Cost in a collapsed group Money, the host projects `{Month=Feb, _Measure=Money}` as `CellId::Aggregate`. A hand-rolled wire `SetCell { record: RecordHash({Month=Feb}), measure: Some(\"Money\") }` is answered `Accepted` and stores `Month=Feb, _Measure=Money = 7` — a non-item coordinate (the improvise-gw18 shape). The record `{Month=Feb}` is in the stored index, so #217's stored arm accepts it without asking the projection; the fold lives entirely in the measure half, which `RecordHash` drops.\n\nRepro: reviewer's `r3_a_wire_write_to_a_collapsed_measure_group_is_rejected` (host.rs), which fails at bc060f67 and with `unwritable` disabled.\n\nCare: simply sending stored records through `cell_id` (or a full-key lookup in the host's layout) would refuse a legit records-mode write of a stored record the HOST's view aggregates over a None-axis category (see improvise-sjdb's pinning test). A fold is a non-item coordinate in any view; a None-axis aggregate is view-dependent. The rule likely wants: refuse a cell whose measure (or any coordinate) is a group name rather than an item, independent of the host's view.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T14:55:43Z","created_by":"Edward Langley","updated_at":"2026-09-23T17:52:52Z","started_at":"2026-09-23T15:27:12Z","closed_at":"2026-09-23T17:52:52Z","close_reason":"Fixed in PR #235, merged as 799a81a after adversarial review at 5c19fdd. Follow-ups: improvise-pr8t, improvise-nacd, improvise-vwc7.","labels":["adversarial-review","pr-217"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ywj2","title":"PasteAt::Resolved is a pub variant over a raw CellKey: 'came from a cursor door' is a convention, not a type","description":"Found by the adversarial delta re-review of PR #197 at 087aa854 (agent-review-197-203b).\n\n`PasteAt::Resolved(CellKey)` (crates/improvise-command/src/command/cmd/cell.rs) is a public variant wrapping a raw key, and `PasteCell::execute` writes a `Resolved` key with `SetCell` and no checks. The only production constructor today is the paste fallback closure in `registry.rs`, which builds it from `ctx.value_cell_key()`. That closure runs only when `parse` refuses, and `parse` refuses only an empty argument list. So nothing is broken today. But the rule \"a Resolved key came from a cursor door\" is a naming convention: any code in the crate, or outside it through the pub path, can write `PasteAt::Resolved(anything)` and skip every check.\n\nThis is the shape `Checked\u003c'_\u003e` was built to close (gotcha 43, `doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The proposed fix is a private-field newtype such as `DoorKey(CellKey)`. `value_cell_key` (and `committable_cell_key` if it helps) returns it, and it is the only way to build one. Then `PasteAt::Resolved` carries a `DoorKey`, and a raw key cannot reach the unchecked arm. The guard is the signature.\n\nThe alternatives: keep the enum and make `Resolved` constructible only through a `pub(super)` fn. That is weaker, for the reason gotcha 43 gives against visibility.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:54:15Z","created_by":"Edward Langley","updated_at":"2026-09-23T14:54:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-tej1","title":"Browser can now compose delete-formula-at-cursor: FormulaCache carries ids since km71","description":"Found by agent-km71 (PR #214). Since the formula projection carries FormulaEntry{id, raw}, the browser can identify a formula, so delete-formula-at-cursor no longer has to be declined in resolve.rs. Compose it, add a lockstep case, and remove it from the 'still needs the host' list.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-km71","owner":"el-github@elangley.org","created_at":"2026-09-23T14:42:22Z","created_by":"orchestrator","updated_at":"2026-09-23T14:43:05Z","started_at":"2026-09-23T14:43:05Z","lease_expires_at":"2026-09-23T14:48:05Z","heartbeat_at":"2026-09-23T14:43:05Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ywj2","title":"PasteAt::Resolved is a pub variant over a raw CellKey: 'came from a cursor door' is a convention, not a type","description":"Found by the adversarial delta re-review of PR #197 at 087aa854 (agent-review-197-203b).\n\n`PasteAt::Resolved(CellKey)` (crates/improvise-command/src/command/cmd/cell.rs) is a public variant wrapping a raw key, and `PasteCell::execute` writes a `Resolved` key with `SetCell` and no checks. The only production constructor today is the paste fallback closure in `registry.rs`, which builds it from `ctx.value_cell_key()`. That closure runs only when `parse` refuses, and `parse` refuses only an empty argument list. So nothing is broken today. But the rule \"a Resolved key came from a cursor door\" is a naming convention: any code in the crate, or outside it through the pub path, can write `PasteAt::Resolved(anything)` and skip every check.\n\nThis is the shape `Checked\u003c'_\u003e` was built to close (gotcha 43, `doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The proposed fix is a private-field newtype such as `DoorKey(CellKey)`. `value_cell_key` (and `committable_cell_key` if it helps) returns it, and it is the only way to build one. Then `PasteAt::Resolved` carries a `DoorKey`, and a raw key cannot reach the unchecked arm. The guard is the signature.\n\nThe alternatives: keep the enum and make `Resolved` constructible only through a `pub(super)` fn. That is weaker, for the reason gotcha 43 gives against visibility.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:54:15Z","created_by":"Edward Langley","updated_at":"2026-09-23T14:54:15Z","dependencies":[{"issue_id":"improvise-ywj2","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-tej1","title":"Browser can now compose delete-formula-at-cursor: FormulaCache carries ids since km71","description":"Found by agent-km71 (PR #214). Since the formula projection carries FormulaEntry{id, raw}, the browser can identify a formula, so delete-formula-at-cursor no longer has to be declined in resolve.rs. Compose it, add a lockstep case, and remove it from the 'still needs the host' list.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-km71","owner":"el-github@elangley.org","created_at":"2026-09-23T14:42:22Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:23Z","started_at":"2026-09-23T14:43:05Z","closed_at":"2026-09-24T21:45:23Z","close_reason":"Landed in main via #223 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lnsh","title":"harness/build.sh: a stale opfs-contract lock prints cargo's hint to remove --locked, not the real remedy","description":"Found reviewing PR #212 at 1550d390 (improvise-mkbp). With a stale harness/opfs-contract/Cargo.lock, build.sh correctly exits 101 in about 4s and leaves the lock untouched. But the only text it prints is cargo's: 'cannot update the lock file ... because --locked was passed' then 'help: ... remove the --locked flag and use --offline instead.' That hint points at deleting the guard. The real remedy, 'cargo update -w in harness/opfs-contract, and commit it' (verified to repair both the pest case and a path-dep version bump), is only in a source comment. Fix: '|| { echo \"harness/build.sh: harness/opfs-contract/Cargo.lock is stale; run cargo update -w there and commit it (improvise-mkbp)\" \u003e\u00262; exit 1; }'.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T06:45:04Z","created_by":"agent-review-212","updated_at":"2026-09-23T15:15:49Z","started_at":"2026-09-23T14:41:46Z","closed_at":"2026-09-23T15:15:49Z","close_reason":"Fixed in PR #218, merged as 0a4f906 after adversarial review at ebf3dd2. Follow-up: improvise-7fl8.","labels":["adversarial-review-pr-212"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7epn","title":"pr-reviews --wait: no test pins polling past the second poll (post-poll deadline check replaceable by break)","description":"Found reviewing PR #212 at 1550d390 (improvise-qnpi). Replacing the new post-poll deadline check in scripts/pr-reviews ('[ \"$(date +%s)\" -lt \"$deadline\" ] || break') with an unconditional 'break' leaves scripts/pr-reviews-test.sh at 23 passed, 0 failed. So --wait N polls at most twice and nothing notices. Every multi-poll fixture resolves on poll 2, and the timeout case expects exit 1 either way. The gap predates #212 (the old loop had the same blind spot), but the line #212 adds is the one no case can see. Fix: a fixture that resolves on poll 3 or later, under the fake clock with --wait large enough for several ticks. Also a case that the loop terminates: deleting the check makes 'times out' hang rather than fail.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-mergetrain","owner":"el-github@elangley.org","created_at":"2026-09-23T06:45:02Z","created_by":"agent-review-212","updated_at":"2026-09-23T15:15:49Z","started_at":"2026-09-23T14:41:45Z","closed_at":"2026-09-23T15:15:49Z","close_reason":"Fixed in PR #218, merged as 0a4f906 after adversarial review at ebf3dd2. Follow-up: improvise-7fl8.","labels":["adversarial-review-pr-212"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-8id2","title":"An old record surrogate resolves through the HOST's layout: [RenameRecordCoordinate(r), SetCell(r)] re-creates r on a pivot host, ChainBroken on a records host","description":"Found while building improvise-fdk1: #204's author asked what the frame [RenameRecordCoordinate(r), SetCell(r's old surrogate)] means. The answer depends on the HOST's active view, not on anything the frame says.\n\nRepro, with the ui/host.rs test scaffolding: North/Revenue = 1. Send ClientFrame::Commands([caught_up(RenameRecordCoordinate { record: North, category: Region, item: West }), caught_up(SetCell { cell: North/Revenue, value: 7 })]).\n\n* **Pivot host** (Region on Row, _Measure on Column): the frame is Accepted. West = 1, and North = 7 is re-created. After the rename, North's surrogate still resolves, because App::resolve_record falls back to record_in_layout, and the pivot layout still draws the empty North/Revenue cell.\n* **Records host** (R pressed): the frame is Rejected(ChainBroken(North)) and nothing applies. record_in_layout excludes records mode, since its keys carry positions.\n\nPinned for the records host only, by ui::host::tests::a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain. Measured for the pivot host, and not pinned.\n\nTwo concerns:\n\n1. A surrogate names COORDINATES, not an object. After a rename, r names the vacated coordinates, so a later write re-creates a record there. That may be acceptable, but it is nowhere stated.\n2. Whether that happens depends on which view the host is on, and a connected client has no say in that (gotcha 29: the model is shared, the UI is not).\n\nReach: the shipped browser composes one rename per frame, so only a hand-rolled client reaches this.\n\nSame fallback as improvise-ciw8 (a collapsed fold resolving through record_in_layout); a fix that makes resolution independent of the host's layout may close both.\n","notes":"ORCHESTRATOR RULING 2026-09-23: option (B), object semantics. A write to a record that an earlier command in its OWN frame renamed is ChainBroken on every host, whatever layout the host has. broken_chain also refuses any record named by an earlier RenameRecordCoordinate in the frame. This extends the rule #221 already pins on the records host. Revisit when ADR 007 (stable ids) lands. Build after #221 merges.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:43Z","created_by":"agent-hzjo2","updated_at":"2026-09-23T15:17:41Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h6z1","title":"Fold evaluation still ~6x gh/main: the per-cell memo re-derives the formula chain for every column","description":"Follow-up to improvise-o1y0 (PR #203). The per-fold-evaluation memo cut a doubling chain from 511 evaluations to 9. On 4,000 cells with F0..F8 as columns the time went from ~1 s to ~60 ms, against ~10 ms on gh/main, which read cached member results and got ratios wrong. The remaining gap: each column's cell re-derives the chain below it, 45 evaluations per row where 9 would do, and each leaf's fold_stored scan repeats for every formula that references it. The fix is a memo shared across one grid build (grid_of / GridLayout::evaluate), keyed by (fold key, formula). Add a test that counts evaluations across a whole row.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:03Z","created_by":"orchestrator","updated_at":"2026-09-23T16:07:31Z","started_at":"2026-09-23T16:07:31Z","lease_expires_at":"2026-09-23T16:12:31Z","heartbeat_at":"2026-09-23T16:07:31Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8id2","title":"An old record surrogate resolves through the HOST's layout: [RenameRecordCoordinate(r), SetCell(r)] re-creates r on a pivot host, ChainBroken on a records host","description":"Found while building improvise-fdk1: #204's author asked what the frame [RenameRecordCoordinate(r), SetCell(r's old surrogate)] means. The answer depends on the HOST's active view, not on anything the frame says.\n\nRepro, with the ui/host.rs test scaffolding: North/Revenue = 1. Send ClientFrame::Commands([caught_up(RenameRecordCoordinate { record: North, category: Region, item: West }), caught_up(SetCell { cell: North/Revenue, value: 7 })]).\n\n* **Pivot host** (Region on Row, _Measure on Column): the frame is Accepted. West = 1, and North = 7 is re-created. After the rename, North's surrogate still resolves, because App::resolve_record falls back to record_in_layout, and the pivot layout still draws the empty North/Revenue cell.\n* **Records host** (R pressed): the frame is Rejected(ChainBroken(North)) and nothing applies. record_in_layout excludes records mode, since its keys carry positions.\n\nPinned for the records host only, by ui::host::tests::a_write_to_a_record_its_own_frame_renamed_is_a_broken_chain. Measured for the pivot host, and not pinned.\n\nTwo concerns:\n\n1. A surrogate names COORDINATES, not an object. After a rename, r names the vacated coordinates, so a later write re-creates a record there. That may be acceptable, but it is nowhere stated.\n2. Whether that happens depends on which view the host is on, and a connected client has no say in that (gotcha 29: the model is shared, the UI is not).\n\nReach: the shipped browser composes one rename per frame, so only a hand-rolled client reaches this.\n\nSame fallback as improvise-ciw8 (a collapsed fold resolving through record_in_layout); a fix that makes resolution independent of the host's layout may close both.\n","notes":"ORCHESTRATOR RULING 2026-09-23: option (B), object semantics. A write to a record that an earlier command in its OWN frame renamed is ChainBroken on every host, whatever layout the host has. broken_chain also refuses any record named by an earlier RenameRecordCoordinate in the frame. This extends the rule #221 already pins on the records host. Revisit when ADR 007 (stable ids) lands. Build after #221 merges.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:43Z","created_by":"agent-hzjo2","updated_at":"2026-09-23T15:17:41Z","dependencies":[{"issue_id":"improvise-8id2","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:05Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h6z1","title":"Fold evaluation still ~6x gh/main: the per-cell memo re-derives the formula chain for every column","description":"Follow-up to improvise-o1y0 (PR #203). The per-fold-evaluation memo cut a doubling chain from 511 evaluations to 9. On 4,000 cells with F0..F8 as columns the time went from ~1 s to ~60 ms, against ~10 ms on gh/main, which read cached member results and got ratios wrong. The remaining gap: each column's cell re-derives the chain below it, 45 evaluations per row where 9 would do, and each leaf's fold_stored scan repeats for every formula that references it. The fix is a memo shared across one grid build (grid_of / GridLayout::evaluate), keyed by (fold key, formula). Add a test that counts evaluations across a whole row.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:41:03Z","created_by":"orchestrator","updated_at":"2026-09-24T21:45:11Z","started_at":"2026-09-23T16:07:31Z","closed_at":"2026-09-24T21:45:11Z","close_reason":"Landed in main via #247 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-y5eo","title":"A document that opens on a records view starts the host in Normal: App::with_storage never settles the mode, and a fresh client gets RecordsNormal","description":"Found reviewing PR #207 (improvise-v8ab) at 13b998d7.\n\n`App::with_storage` sets `view_state.mode` to `Normal` and never settles it against the layout. The host settles only at the end of a batch (`settle_normal_mode` in `apply_effects`). A session's mirror settles in `project_diff` (`settle_mode`, project.rs), so `Sessions::connect` hands a client `RecordsNormal`.\n\nReproduced at 13b998d7 with a document whose persisted view puts `_Index: row` and `_Dim: column` (the `.improv` writer does persist those axes):\n- `App::new(wb).view_state.mode == Normal`, while `app.layout.is_records_mode() == true`.\n- `Sessions::connect(\u0026app).1.view.mode == RecordsNormal`, so the host's startup slice and a fresh client's differ in `mode` and nothing else.\n- The host's first `i` resolves through Normal's keymap and lands in plain `Editing`, not `RecordsEditing`.\n\nFix shape: settle the mode in `App::with_storage` (and check `install_workbook`), using the same `improvise_protocol::settled_mode` both ends use. Then add a records-opening document to `a_fresh_sessions_slice_is_the_hosts_startup_slice`, which currently covers pivot documents only and so cannot see this.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T06:40:07Z","created_by":"agent-review-207","updated_at":"2026-09-23T14:56:20Z","started_at":"2026-09-23T06:41:36Z","closed_at":"2026-09-23T14:56:20Z","close_reason":"Fixed in PR #216, merged as cd55944 after adversarial review at 08b87ed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oga4","title":"Review-reply rules stated twice (AGENTS.md + agent-rules); code-review skill lacks the reviewer rule; ADR merge gate ambiguous vs 'merge only once a review has passed'","description":"Found by the adversarial delta review of PR #201 at head 141be9fc.\n\n1. Two rules are stated in two places.\n - Reviewers answer every Codex review on its thread and state the head SHA they reviewed. This is in agent-rules.md \"GitHub\" and also in AGENTS.md \"Shipping a wave\": \"answer it on the thread even if the PR has already merged\" and \"it must name the head SHA it reviewed\".\n - Authors reply with a reason. This is in agent-rules \"GitHub\" and also in AGENTS.md's Copilot paragraph: \"when you decline, reply on the thread with the reason\".\n - Neither file points to the other for these rules. agent-rules' preamble says the rule lives there and AGENTS.md carries the history, so AGENTS.md should point rather than restate.\n2. There is a missing pointer. .agents/skills/code-review/SKILL.md, the skill a reviewer actually loads, does not mention reading the existing reviews first (scripts/pr-reviews), answering Codex, or stating the head SHA. Only a wave brief that names agent-rules \"GitHub\" delivers that rule.\n3. Question for Edward: does an ADR PR still need CI and an adversarial review? AGENTS.md \"ADR pull requests\" says an ADR \"merges on the user's approval, not on a review\". \"Repository policy over the Beads blocks\" says \"the merge is the orchestrator's, after an adversarial review passes the head SHA\", and \"Shipping a wave\" says \"Merge only once a review has passed\". One reading makes \"Approved\" replace review. The other makes it an extra gate.\n - #122 had its Codex reviews answered and CI green before \"Approved\".\n - The user's own review on #122 (\"Keep full color support in themes\") landed as the PR merged. That argues for re-reading reviews after \"Approved\".\n - Suggested wording: \"on the user's approval, in addition to green CI and every review answered\".\n\nAcceptance: each rule has one home and every other mention points to it; the code-review skill points to the reviewer rule; the ADR gating is stated unambiguously per Edward's answer.\n","notes":"Orchestrator's reading of D, pending the user: an ADR PR needs ALL of green CI, every review answered on the thread, an adversarial review, AND the user's 'Approved'. The user's approval is required, but it doesn't replace the other gates. The fix is the reviewer's wording, 'in addition to green CI and every review answered'.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:28:34Z","created_by":"agent-review-201b","updated_at":"2026-09-23T15:15:50Z","started_at":"2026-09-23T06:30:18Z","closed_at":"2026-09-23T15:15:50Z","close_reason":"Fixed in PR #211, merged as 968f65a after adversarial review (delta at 41aa407).","comments":[{"id":"d5acc6ff-e6f1-5810-8c52-84e341c7bcf8","issue_id":"improvise-oga4","author":"agent-review-211","text":"Review of PR #211 at 6ccad4b8 (agent-review-211). The ADR section and the wave SKILL now state the ruling. But two general statements of the merge condition in AGENTS.md still read as sufficient, and neither points to the ADR exception:\n\n- \"Shipping a wave\", the opening paragraph: work \"lands on `main` by being merged there, once CI is green and an adversarial review agent has passed the PR's head SHA\".\n- \"Repository policy over the Beads blocks\": \"the merge is the orchestrator's, after an adversarial review passes the head SHA\".\n\nAn orchestrator reading either one without reaching \"ADR pull requests\" 130 lines later has a green-plus-review merge rule that covers ADR PRs. A parenthetical pointer in each fixes it: \"(an ADR PR also needs the user's approval: 'ADR pull requests')\".\n\nAlso, `.agents/skills/wave/SKILL.md` line 84 now wraps the pointer `\"ADR pull` / `requests\"` across two lines, so a grep for the section name misses it. It was on one line before this PR. And \"the user's explicit approval accepts or merges an ADR PR\" says the approval *merges* the PR. AGENTS.md says it *lets* the PR merge.\n","created_at":"2026-09-23T14:48:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-q2mq","title":"RecordRewrite: a blank edit on a measure a renamed drill row lacks clears at the renamed key, and no test can fail on it","description":"Found by adversarial review of PR #209 (improvise-phg1) at head ccb517c6.\n\nThe code is correct. What is missing is a test that can fail. In `RecordRewrite::of`, the second loop handles measures the row has no cell for. A **blank** edit there pushes a clear at the *renamed* key (`None =\u003e rewrite.clears.push(key)`). Change that one arm to clear the unrenamed key instead, `row.cell_key(measure)`. The whole workspace stays green (`cargo test --workspace --no-fail-fast`, exit 0).\n\nphg1(b)'s test covers only the `Some` arm of that loop. phg1(c)'s test does run the `None` arm, but the clear it pushes has no visible effect there, because row 1 has already read and moved the destination's `Profit`. The unrenamed key is the row's own old record, which by construction lacks the measure. So the rival clears nothing, and the difference shows only when the rename destination already holds that measure.\n\nA probe that separates them passes at ccb517c6 and fails under the rival. It uses the `assert_drill_over` helper in `ui/effect.rs` tests:\n\n cells: Food/Jan/Revenue=1, Clothing/Jan/Revenue=42, Clothing/Jan/Profit=5\n edits: (0, Type, \"Clothing\"), (0, Profit, \"\")\n expected: {\"Clothing/Jan/Revenue = 1\"}\n\nUnder the rival the result is `{\"Clothing/Jan/Profit = 5\", \"Clothing/Jan/Revenue = 1\"}`. A row renamed onto an existing record, blanking a measure it lacks, fails to blank that measure on the merged record.\n\nFix: add the probe as a test, and add evidence in context/test-evidence/command.md.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T06:21:11Z","created_by":"agent-review-209","updated_at":"2026-09-23T06:39:04Z","started_at":"2026-09-23T06:25:51Z","closed_at":"2026-09-23T06:39:04Z","close_reason":"Fixed in PR #209, merged as ea119bd after adversarial review at ccb517c. q2mq was the reviewer-requested fourth test; the merge touched evidence only.","labels":["adversarial-review","pr-209"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-lnkb","title":"IS on a folded category is true on the fold row and false on every row it replaces and on Axis::None","description":"Found by the adversarial review of PR #203 at head 2a4f35d7. context/code/core.md §14 documents this as a residual difference. This ticket argues that it is wrong, not merely different.\n\nOn a fold row, the key carries the group name (`Month=Q1`), and `eval_is` compares coordinates by string. So:\n\n- `I = Month IS Q1` gives **1** on the collapsed Q1 row. It gives **0** on Jan and on Feb, the rows the fold replaces. It gives **0** with Month on `Axis::None`.\n- `I = IF(Month IS Q1 \u003e 0.5, Revenue, 0)` gives **64** on the fold. It gives 0 on every member row, and 0 on None.\n- `Month IS Jan` gives 0 on the fold, which agrees with None. `SUM(Month IS Jan)` gives 3 on both, which also agrees.\n\nThe same PR states two rules: a fold is exactly the rows it replaces (f2oo), and a formula on a fold equals the None-axis answer (eccl). A group name is not an item, and `Month=Q1` exists only as a presentation device of a collapsed view. So `IS` lets a formula observe collapse state, and its value flips when the user presses `z`. There are two principled answers:\n\n1. It is false, as on None. `IS` names items, and no record carries Q1.\n2. It is membership, true on the fold **and** on Jan and Feb.\n\nEither answer would make the fold agree with the rows it replaces. The current answer agrees with nothing. The inline `WHERE Month = Q1` already behaves like (1): it is 0 on the fold and 0 on None.\n\nLow priority: the case needs a formula that names a group in `IS`.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T06:07:06Z","created_by":"agent-review-203","updated_at":"2026-09-23T15:28:30Z","started_at":"2026-09-23T06:13:13Z","closed_at":"2026-09-23T15:28:30Z","close_reason":"Fixed in PR #203, merged after adversarial re-review at b3379d1; the later changes were evidence-only merges and a docs-only fix to §14. Follow-ups: improvise-h6z1, improvise-d7aj (#232), improvise-wm20.","labels":["adversarial-review pr-203"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-fz77","title":"Categories and items cannot be renamed from the category panel","description":"Reported by the user, 2026-09-23 ('maybe a similar issue in the category pane, but it's less annoying there'). The category panel can add a category or item and delete one, but has no rename. The only way to rename is to delete and recreate, which loses the data under the category or item. Expected: a rename key on the entry under the cursor opens a minibuffer seeded with its name, and the commit renames it everywhere: data keys, views' axis maps, page selections, hidden items and groups, and formulas that reference it. Check first whether Workbook/Model already has a rename operation. This needs a ModelCommand for the wire, and both front ends.","notes":"Orchestrator context, 2026-09-23: identity is the name. Category and Item have a usize 'id' (category.rs), but nothing keys on it. It is returned by add_category/add_item and never persisted in .improv. Model.categories is an IndexMap keyed by name, CellKey and DataStore symbols are names, and views, hidden/collapsed and formulas all refer by name. A Formula has no id at all: {raw, target, target_category, expr, filter} in a Vec, in effect identified by (target, target_category). So a rename must rewrite every name reference, or the design must first introduce a stable identity. Weigh the two before implementing.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T06:03:25Z","created_by":"orchestrator","updated_at":"2026-09-23T06:03:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-fz77","title":"Categories and items cannot be renamed from the category panel","description":"Reported by the user, 2026-09-23 ('maybe a similar issue in the category pane, but it's less annoying there'). The category panel can add a category or item and delete one, but has no rename. The only way to rename is to delete and recreate, which loses the data under the category or item. Expected: a rename key on the entry under the cursor opens a minibuffer seeded with its name, and the commit renames it everywhere: data keys, views' axis maps, page selections, hidden items and groups, and formulas that reference it. Check first whether Workbook/Model already has a rename operation. This needs a ModelCommand for the wire, and both front ends.","notes":"Orchestrator context, 2026-09-23: identity is the name. Category and Item have a usize 'id' (category.rs), but nothing keys on it. It is returned by add_category/add_item and never persisted in .improv. Model.categories is an IndexMap keyed by name, CellKey and DataStore symbols are names, and views, hidden/collapsed and formulas all refer by name. A Formula has no id at all: {raw, target, target_category, expr, filter} in a Vec, in effect identified by (target, target_category). So a rename must rewrite every name reference, or the design must first introduce a stable identity. Weigh the two before implementing.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-23T06:03:25Z","created_by":"orchestrator","updated_at":"2026-09-23T06:03:47Z","dependencies":[{"issue_id":"improvise-fz77","depends_on_id":"improvise-cq1q.4","type":"blocks","created_at":"2026-09-25T04:51:04Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-fz77","depends_on_id":"improvise-cq1q.8","type":"blocks","created_at":"2026-09-25T04:51:05Z","created_by":"agent-adr007","metadata":"{}"},{"issue_id":"improvise-fz77","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ciw8","title":"Host resolve_cell resolves a collapsed fold's surrogate via record_in_layout: a wire SetCell stores Month=Q2 and the frame is Accepted","description":"Found in the adversarial review of PR #197 at head 097533e5. This is pre-existing on gh/main and is not introduced by #197. It is the host-side door that #197's cursor doors do not cover.\n\nRepro (a Host frame, `ui/host.rs` test scaffolding): Month in Q1/Q2 groups on Row, _Measure on Column, Q2 collapsed. Send `ClientFrame::Commands([caught_up(SetCell { cell: RecordCell { record: RecordHash::of({Month=Q2, _Measure=Cost}) or RecordHash::of({Month=Q2}), measure: Some(\"Cost\") }, value: 7 })])`. The answer is `Accepted`, and the store now holds `Month=Q2, _Measure=Cost = 7`. That is exactly the non-item coordinate that improvise-gw18/l4mp exist to prevent.\n\nCause: `App::resolve_cell` -\u003e `resolve_record` falls back to `record_in_layout`, which accepts ANY key the layout names, including a collapsed group's fold. `cell_id` projects that same position as `Aggregate`. The host therefore resolves a surrogate that its own projection says is not a record.\n\nReach: the shipped browser never composes this, because `record_cell_at_cursor` sees `Aggregate`. So it takes a hand-rolled or buggy client. Basis is not a security boundary (gotcha 43), but this is a resolution question and not an anti-staleness one: a record surrogate should resolve only to a position `cell_id` would call `Record`.\n\nFix direction: `record_in_layout` should skip positions where `layout.folds_a_group(r, c)` holds, or ask `project::cell_id`. A refused resolution should also reject the frame (the `vec![]` arm in `model_effects` is accepted silently today).\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:55:55Z","created_by":"agent-review-197","updated_at":"2026-09-23T15:35:48Z","started_at":"2026-09-23T06:41:31Z","closed_at":"2026-09-23T15:35:48Z","close_reason":"Fixed in PR #217, merged as cc9bc41 after adversarial review (bc060f6, then the sjdb delta at 52fa397). Follow-ups: improvise-b114, improvise-xur5.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e265","title":"prune_empty changes a surviving row's group on a two-level grouped axis: GroupEnd carries no level, so #200's pruned axis puts North/Mar in Upper where the unpruned one says no group","description":"Found reviewing PR #200 (improvise-b5zh) at 561cff47.\n\n`AxisEntry::GroupEnd` carries no level, and every consumer of membership is flat: `GridLayout::row_group_for`/`col_group_for` (what `z` / toggle-group-under-cursor collapses) and `AxisFrame::groups`/`group_runs` (what the TUI column band and the browser draw). On an axis with two grouped categories, pruning now changes a surviving row's group.\n\nRepro (Region outer: North in Upper; Month inner: Jan in Q1, Mar ungrouped; only North/Mar funded):\n- prune off: `[Upper, Q1, North/Jan, GroupEnd, North/Mar]`, so `row_group_for(North/Mar)` is `None`\n- prune on at #200: `[Upper, North/Mar]`, so it is `Some(Region, Upper)`\n- prune on at gh/main 98d10ca5: `[Upper, GroupEnd, North/Mar]`, so it is `None`. The layouts agree there, but Upper is a header drawn over nothing.\n\nEnumerated over 300,580 (axis x collapse x prune-mask) cases (2- and 3-level, collapsed and non-contiguous groups):\n- gh/main: 274,584 header-over-nothing, 0 membership changes.\n- #200: 0 header-over-nothing, 45,806 membership changes. Every one is this shape: the inner header was pruned, so its GroupEnd is dropped too, and the ungrouped inner item joins the outer group.\n\nUnder a flat membership model the two properties cannot both hold. In the unpruned layout the outer group has no flat members (the inner header takes over at once), so the unpruned answer is the lossy one. #200's pruned answer, that North/Mar is in Upper, is arguably the right one per level. The fix is a levelled GroupEnd, or per-level membership, so that prune on and prune off give the same answer. Choose which before fixing.\n\nA related cosmetic issue, on gh/main and #200 both: a pruned axis can end in a trailing GroupEnd (75k cases at #200, 57k on main), which `cross_product` strips from the unpruned axis.\n","notes":"USER RULING 2026-09-23: membership is per level (option A, levelled group markers), and z toggles the innermost group, the one closest to the cursor.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:36Z","created_by":"agent-review-191-200-198","updated_at":"2026-09-23T18:00:32Z","started_at":"2026-09-23T18:00:32Z","lease_expires_at":"2026-09-23T18:05:32Z","heartbeat_at":"2026-09-23T18:00:32Z","comments":[{"id":"b83ba7c3-248b-5435-a96f-a222009d197f","issue_id":"improvise-e265","author":"agent-docs-wave","text":"_Posted by Claude (Opus 5.5), an agent in Edward Langley's session. This is a design write-up; nothing is implemented._\n\n# improvise-e265: design options\n\n## Where things stand (checked at gh/main 5932ad74)\n\n#200 has merged, so the drift in membership is what `main` does now. The ticket's example, run through a scratch probe (not committed) against `GridLayout::with_frozen_records`:\n\n| prune | `row_items` | `row_group_for(North/Mar)` |\n|---|---|---|\n| off | `[Upper, Q1, North/Jan, GroupEnd, North/Mar]` | `None` |\n| on | `[Upper, North/Mar]` | `Some(Region, Upper)` |\n\nNeither answer is correct at every level. North/Mar is in `Upper` at level 0 and in no `Month` group at level 1.\n\nThe cause is not really pruning. It is the flat interpretation of the markers. `Month`'s `GroupEnd` is read as \"close everything\", so `Upper`'s run ends at an inner boundary. A flat reading can't reconcile the two layouts: whichever one it matches, the other disagrees.\n\n## Who reads membership\n\n- **Core:** `GridLayout::row_group_for` / `col_group_for`, which decide what `z` (`ToggleGroupAtCursor`) collapses.\n- **Protocol:** `AxisFrame::groups`, `group_of`, `group_runs`, `group_run_heads`.\n- **TUI:** the column group band (`src/ui/grid.rs` uses `groups()` and `group_run_heads()`). Rows draw headers inline from the entries and never ask about membership.\n- **Browser:** `col_groups` in `wasm-client/src/render.rs` (uses `group_runs()`). `z` is a no-op in the browser (improvise-uln5), so it reads no row membership.\n- **Also affected:** `app.rs`'s layout fingerprint hashes `GroupEnd`; `axis_frame` (projection windowing) and `prune_axis_items` both produce marker sequences.\n\n## Options\n\n### A. Levelled markers with stack membership (recommended)\n\nEach marker says which level it belongs to:\n- core: `AxisEntry::GroupHeader { cat_name, group_name }` is unchanged, since its level is `cats.position(cat_name)`, and `GroupEnd` becomes `GroupEnd { level }`;\n- wire: `FrameGroup { level, name, collapsed }` and `FrameEntry::GroupEnd { level }`.\n\nMembership becomes a per-level stack:\n- a header at level L sets `open[L]` and clears every deeper level;\n- `GroupEnd { level: L }` clears level L and every deeper level.\n\nAn item's membership is the whole stack. The single answer `row_group_for` and `groups()` return is the **innermost open level**. For North/Jan that is still `Q1`, as today. For North/Mar it becomes `Upper`, whether or not pruning is on.\n\nPruning keeps a `GroupEnd` only when the header it closes was kept. Matching is by level, so dropping an inner pair can't close an outer run. The same trailing-`GroupEnd` strip `cross_product` applies runs after pruning too, which fixes the cosmetic issue in the ticket.\n\nCosts:\n- **Core `layout.rs`:**\n - `expand_category` sets `level` on `GroupEnd`.\n - `prune_axis_items`: its `pending` becomes a per-level stack, and `close_runs_of` already truncates by category.\n - `row_group_for` / `col_group_for` walk a stack instead of finding the nearest marker.\n - The trailing-strip becomes one helper that both `cross_product` and prune call.\n - `run_start` is untouched.\n - The about 12 `GroupEnd` literals in tests gain a level.\n- **`app.rs` fingerprint:** hash the level as well. One line.\n- **`project.rs::axis_frame`:** a windowing rule changes. Before the window opens it now keeps the nearest header **per level** (at most `cats.len()` entries), not just the single nearest one. Otherwise a window opening inside `North/Q1` loses `Upper`. The payload stays O(levels), not O(groups), so the reason in `command.md` (\"only the nearest, or the payload grows\") still holds with \"per level\" added.\n- **Protocol `render.rs`:**\n - `groups()` and `group_of` keep a stack (one pass still).\n - `group_runs` / `group_run_heads` are unchanged: they coalesce whatever `groups()` returns, and h0iv's `std::ptr::eq` rule still applies.\n - About 13 `FrameGroup` literals and 6 `GroupEnd` literals gain a level.\n - `body_from` (0o6x) needs checking: at the top of a windowed axis it must drop every carried header, not only one.\n- **TUI:** no code change beyond constructing the new variants. The band still shows one group per column, now the innermost open one. In the example, the band over North/Mar reads `Upper` where it now reads nothing.\n- **Browser:** the same. `col_groups` is unchanged.\n- **Wire:** a serde shape change to `FrameGroup` and `FrameEntry::GroupEnd`. Both ends are one bundle, and the cache-bust stamp covers a stale page.\n- **A side effect worth having:** `level` indexes `AxisFrame::cats`, so a client can name the category of any group. That is the piece a browser `z` would need to compose `ToggleGroup` (improvise-uln5), and it does not bring back the dead field improvise-7xyx removed. `level` is read by `groups()`, so it is live, and the category *name* comes from `cats` rather than being carried a second time.\n\nWhy the level goes on `GroupEnd` and not only on the header: in principle a stack could pair an unlevelled `GroupEnd` with its header by position. That only works if every producer emits a balanced sequence, and there are three producers: `expand_category`, `prune_axis_items` and `axis_frame`'s windowing. The last two both cut sequences. A levelled marker can be read correctly on its own, even inside a pruned or windowed fragment. Every bug in this area (gd83, u6gc, lhi4, b5zh, and now e265) has come from a producer changing marker placement, so a marker that can be read without its neighbours is the property to buy.\n\n### B. Membership stored on the item; markers derived\n\n`DataItem` carries `groups: Vec\u003cOption\u003cString\u003e\u003e`, parallel to `tuple`, filled in `expand_category`, which is the one place it is known. Membership becomes an attribute of the item, so pruning can't change it. `prune_axis_items` becomes: filter the data items, then **re-derive** the markers from the survivors with the same function expansion uses. That removes the `pending`/`run_open`/`close_runs_of` machinery, which is where the bugs listed above lived. It also removes `GroupEnd` completely, since it exists only so that membership is not inherited. Both \"header over nothing\" and \"trailing `GroupEnd`\" become impossible to represent.\n\nCosts:\n- **Core:** the largest rewrite of the three. The `DataItem` shape, `expand_category`, `prune_axis_items`, `cross_product`, `run_start`, `row_group_for`, the fingerprint, and every `AxisEntry` literal in the tests.\n- **Wire:** `FrameEntry::Item` would carry per-level groups. That is O(items × levels) in the payload where A is O(markers). Otherwise the host still has to send levelled markers, and the client-side membership walk is option A anyway. `FrameEntry::GroupEnd` would be deleted.\n- **h0iv must be argued again.** Runs can no longer be \"opened by the same header entry\" if the header is derived. The key becomes `(prefix up to L, level, group)`, which does separate `North/Q1` from `South/Q1`. But it is a new rule, and the h0iv evidence has to be re-pinned.\n- **Both front ends:** every `FrameEntry` match changes.\n\nB is the stronger invariant: an invariant made impossible to break, not one that is validated. It is also about three times the change, and its wire cost is real.\n\n### C. Membership computed before pruning (the ticket's example option)\n\nCompute `row_group_for` on the unpruned layout and carry it through pruning. This fixes the lossy flat answer, and makes it canonical: North/Mar is `None` while the user sees it under the `Upper` header row. It still needs either a second layout or an attribute carried per item. The second of those is B's cost with a worse answer, so I recommend rejecting C.\n\n### D. Keep flat membership and choose which property to lose\n\nFor example, go back to pre-#200 behaviour: `[Upper, GroupEnd, North/Mar]`, with membership stable and a header drawn over nothing. This is the cheapest option, and the ticket's enumeration shows flat membership cannot make both properties hold (274,584 cases against 45,806 at the two ends). Only acceptable as a documented leftover.\n\n## Recommendation\n\n**A.** It is the smallest type change that makes pruning on and off agree while also giving the answer that is correct at every level. It keeps h0iv's identity rule and the windowing payload bound. It also gives the browser what it would need to name a group's category for `z`.\n\nIf the user would rather pay for the invariant, B is the right shape. In that case I would take B in core first and put A's levelled markers on the wire: the host derives them, so the wire does not pay for O(items × levels).\n\n## Questions for the user\n\n1. **Which group does a row belong to?** Under A (and B), North/Mar is in `Upper`: `z` there collapses `Upper`, and the column band shows `Upper` over it. Today, unpruned, `z` there does nothing and the band is blank. Is the per-level answer the one you want?\n2. **What does `z` toggle when a row is in more than one open group?** I propose the innermost, which matches what `z` does today on North/Jan (`Q1`).\n3. **Is one column band enough?** A still paints one group per column (the innermost). One band per level would be a separate feature; I'd file it rather than fold it into this.\n\n## Proposed evidence for the fix\n\n- A property test over 2- and 3-level axes × collapse state × prune masks, the ticket's own enumeration, now as an assertion:\n - for every item that survives pruning, the membership stack of the pruned layout equals the unpruned layout's;\n - every header that is kept has at least one member.\n - Rival: today's flat `prune_axis_items`. It should fail on the ticket's 45,806-case shape. A pre-#200 rival should fail the no-empty-header half instead.\n- A frame-agreement test: `AxisFrame::groups()` on the projected frame equals `GridLayout`'s per-item membership, including a window that opens inside `North/Q1`. That is the per-level windowing rule, and a nearest-single-header rival must fail it.\n","created_at":"2026-09-23T16:01:42Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-bf0q","title":"bd setup factory calls #201's minimal Beads block stale at once and swaps in profile:full; bd -C is ignored by bd setup","description":"Found reviewing PR #201 (head 31b5acdb), in a scratch copy of the tree with bd 1.3.0.\n\n1. `bd setup factory --check` reports the block #201 installs (`profile:minimal hash:46cd31e7`) as **stale** straight away, not only \"whenever bd's template has moved on\" as #201's new \"The Beads blocks are bd's\" section says. Running `bd setup factory` then replaces it with `profile:full hash:bacef91e`. That render is much longer, uses `--json` everywhere, and prescribes `git pull --rebase`. After the rewrite `--check` reports \"(current)\". So the first person to follow bd's own advice flips the profile. The repo-owned section outside the markers survives, verified byte for byte, and it already names the full profile's `git pull --rebase`. The policy therefore holds, but the sentence about when bd calls the block stale should say it happens at once for factory.\n\n2. **`bd -C \u003cdir\u003e setup factory` ignores `-C`** and writes `AGENTS.md` in the current working directory. Run from an agent's worktree with `-C /tmp/scratch`, it rewrote the worktree's `AGENTS.md` and left the scratch copy untouched, and `--check` read the worktree too. Anyone told to \"verify bd's regeneration on a scratch copy\" must `cd` into the copy and must not trust `-C`. This is an upstream bd bug, recorded here because the scratch-copy instruction will be given again.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T05:53:27Z","dependencies":[{"issue_id":"improvise-bf0q","depends_on_id":"improvise-x1uk","type":"discovered-from","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e265","title":"prune_empty changes a surviving row's group on a two-level grouped axis: GroupEnd carries no level, so #200's pruned axis puts North/Mar in Upper where the unpruned one says no group","description":"Found reviewing PR #200 (improvise-b5zh) at 561cff47.\n\n`AxisEntry::GroupEnd` carries no level, and every consumer of membership is flat: `GridLayout::row_group_for`/`col_group_for` (what `z` / toggle-group-under-cursor collapses) and `AxisFrame::groups`/`group_runs` (what the TUI column band and the browser draw). On an axis with two grouped categories, pruning now changes a surviving row's group.\n\nRepro (Region outer: North in Upper; Month inner: Jan in Q1, Mar ungrouped; only North/Mar funded):\n- prune off: `[Upper, Q1, North/Jan, GroupEnd, North/Mar]`, so `row_group_for(North/Mar)` is `None`\n- prune on at #200: `[Upper, North/Mar]`, so it is `Some(Region, Upper)`\n- prune on at gh/main 98d10ca5: `[Upper, GroupEnd, North/Mar]`, so it is `None`. The layouts agree there, but Upper is a header drawn over nothing.\n\nEnumerated over 300,580 (axis x collapse x prune-mask) cases (2- and 3-level, collapsed and non-contiguous groups):\n- gh/main: 274,584 header-over-nothing, 0 membership changes.\n- #200: 0 header-over-nothing, 45,806 membership changes. Every one is this shape: the inner header was pruned, so its GroupEnd is dropped too, and the ungrouped inner item joins the outer group.\n\nUnder a flat membership model the two properties cannot both hold. In the unpruned layout the outer group has no flat members (the inner header takes over at once), so the unpruned answer is the lossy one. #200's pruned answer, that North/Mar is in Upper, is arguably the right one per level. The fix is a levelled GroupEnd, or per-level membership, so that prune on and prune off give the same answer. Choose which before fixing.\n\nA related cosmetic issue, on gh/main and #200 both: a pruned axis can end in a trailing GroupEnd (75k cases at #200, 57k on main), which `cross_product` strips from the unpruned axis.\n","notes":"USER RULING 2026-09-23: membership is per level (option A, levelled group markers), and z toggles the innermost group, the one closest to the cursor.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:36Z","created_by":"agent-review-191-200-198","updated_at":"2026-09-24T21:45:09Z","started_at":"2026-09-23T18:00:32Z","closed_at":"2026-09-24T21:45:09Z","close_reason":"Landed in main via #250 (reviewed; merged 2026-09-24).","comments":[{"id":"b83ba7c3-248b-5435-a96f-a222009d197f","issue_id":"improvise-e265","author":"agent-docs-wave","text":"_Posted by Claude (Opus 5.5), an agent in Edward Langley's session. This is a design write-up; nothing is implemented._\n\n# improvise-e265: design options\n\n## Where things stand (checked at gh/main 5932ad74)\n\n#200 has merged, so the drift in membership is what `main` does now. The ticket's example, run through a scratch probe (not committed) against `GridLayout::with_frozen_records`:\n\n| prune | `row_items` | `row_group_for(North/Mar)` |\n|---|---|---|\n| off | `[Upper, Q1, North/Jan, GroupEnd, North/Mar]` | `None` |\n| on | `[Upper, North/Mar]` | `Some(Region, Upper)` |\n\nNeither answer is correct at every level. North/Mar is in `Upper` at level 0 and in no `Month` group at level 1.\n\nThe cause is not really pruning. It is the flat interpretation of the markers. `Month`'s `GroupEnd` is read as \"close everything\", so `Upper`'s run ends at an inner boundary. A flat reading can't reconcile the two layouts: whichever one it matches, the other disagrees.\n\n## Who reads membership\n\n- **Core:** `GridLayout::row_group_for` / `col_group_for`, which decide what `z` (`ToggleGroupAtCursor`) collapses.\n- **Protocol:** `AxisFrame::groups`, `group_of`, `group_runs`, `group_run_heads`.\n- **TUI:** the column group band (`src/ui/grid.rs` uses `groups()` and `group_run_heads()`). Rows draw headers inline from the entries and never ask about membership.\n- **Browser:** `col_groups` in `wasm-client/src/render.rs` (uses `group_runs()`). `z` is a no-op in the browser (improvise-uln5), so it reads no row membership.\n- **Also affected:** `app.rs`'s layout fingerprint hashes `GroupEnd`; `axis_frame` (projection windowing) and `prune_axis_items` both produce marker sequences.\n\n## Options\n\n### A. Levelled markers with stack membership (recommended)\n\nEach marker says which level it belongs to:\n- core: `AxisEntry::GroupHeader { cat_name, group_name }` is unchanged, since its level is `cats.position(cat_name)`, and `GroupEnd` becomes `GroupEnd { level }`;\n- wire: `FrameGroup { level, name, collapsed }` and `FrameEntry::GroupEnd { level }`.\n\nMembership becomes a per-level stack:\n- a header at level L sets `open[L]` and clears every deeper level;\n- `GroupEnd { level: L }` clears level L and every deeper level.\n\nAn item's membership is the whole stack. The single answer `row_group_for` and `groups()` return is the **innermost open level**. For North/Jan that is still `Q1`, as today. For North/Mar it becomes `Upper`, whether or not pruning is on.\n\nPruning keeps a `GroupEnd` only when the header it closes was kept. Matching is by level, so dropping an inner pair can't close an outer run. The same trailing-`GroupEnd` strip `cross_product` applies runs after pruning too, which fixes the cosmetic issue in the ticket.\n\nCosts:\n- **Core `layout.rs`:**\n - `expand_category` sets `level` on `GroupEnd`.\n - `prune_axis_items`: its `pending` becomes a per-level stack, and `close_runs_of` already truncates by category.\n - `row_group_for` / `col_group_for` walk a stack instead of finding the nearest marker.\n - The trailing-strip becomes one helper that both `cross_product` and prune call.\n - `run_start` is untouched.\n - The about 12 `GroupEnd` literals in tests gain a level.\n- **`app.rs` fingerprint:** hash the level as well. One line.\n- **`project.rs::axis_frame`:** a windowing rule changes. Before the window opens it now keeps the nearest header **per level** (at most `cats.len()` entries), not just the single nearest one. Otherwise a window opening inside `North/Q1` loses `Upper`. The payload stays O(levels), not O(groups), so the reason in `command.md` (\"only the nearest, or the payload grows\") still holds with \"per level\" added.\n- **Protocol `render.rs`:**\n - `groups()` and `group_of` keep a stack (one pass still).\n - `group_runs` / `group_run_heads` are unchanged: they coalesce whatever `groups()` returns, and h0iv's `std::ptr::eq` rule still applies.\n - About 13 `FrameGroup` literals and 6 `GroupEnd` literals gain a level.\n - `body_from` (0o6x) needs checking: at the top of a windowed axis it must drop every carried header, not only one.\n- **TUI:** no code change beyond constructing the new variants. The band still shows one group per column, now the innermost open one. In the example, the band over North/Mar reads `Upper` where it now reads nothing.\n- **Browser:** the same. `col_groups` is unchanged.\n- **Wire:** a serde shape change to `FrameGroup` and `FrameEntry::GroupEnd`. Both ends are one bundle, and the cache-bust stamp covers a stale page.\n- **A side effect worth having:** `level` indexes `AxisFrame::cats`, so a client can name the category of any group. That is the piece a browser `z` would need to compose `ToggleGroup` (improvise-uln5), and it does not bring back the dead field improvise-7xyx removed. `level` is read by `groups()`, so it is live, and the category *name* comes from `cats` rather than being carried a second time.\n\nWhy the level goes on `GroupEnd` and not only on the header: in principle a stack could pair an unlevelled `GroupEnd` with its header by position. That only works if every producer emits a balanced sequence, and there are three producers: `expand_category`, `prune_axis_items` and `axis_frame`'s windowing. The last two both cut sequences. A levelled marker can be read correctly on its own, even inside a pruned or windowed fragment. Every bug in this area (gd83, u6gc, lhi4, b5zh, and now e265) has come from a producer changing marker placement, so a marker that can be read without its neighbours is the property to buy.\n\n### B. Membership stored on the item; markers derived\n\n`DataItem` carries `groups: Vec\u003cOption\u003cString\u003e\u003e`, parallel to `tuple`, filled in `expand_category`, which is the one place it is known. Membership becomes an attribute of the item, so pruning can't change it. `prune_axis_items` becomes: filter the data items, then **re-derive** the markers from the survivors with the same function expansion uses. That removes the `pending`/`run_open`/`close_runs_of` machinery, which is where the bugs listed above lived. It also removes `GroupEnd` completely, since it exists only so that membership is not inherited. Both \"header over nothing\" and \"trailing `GroupEnd`\" become impossible to represent.\n\nCosts:\n- **Core:** the largest rewrite of the three. The `DataItem` shape, `expand_category`, `prune_axis_items`, `cross_product`, `run_start`, `row_group_for`, the fingerprint, and every `AxisEntry` literal in the tests.\n- **Wire:** `FrameEntry::Item` would carry per-level groups. That is O(items × levels) in the payload where A is O(markers). Otherwise the host still has to send levelled markers, and the client-side membership walk is option A anyway. `FrameEntry::GroupEnd` would be deleted.\n- **h0iv must be argued again.** Runs can no longer be \"opened by the same header entry\" if the header is derived. The key becomes `(prefix up to L, level, group)`, which does separate `North/Q1` from `South/Q1`. But it is a new rule, and the h0iv evidence has to be re-pinned.\n- **Both front ends:** every `FrameEntry` match changes.\n\nB is the stronger invariant: an invariant made impossible to break, not one that is validated. It is also about three times the change, and its wire cost is real.\n\n### C. Membership computed before pruning (the ticket's example option)\n\nCompute `row_group_for` on the unpruned layout and carry it through pruning. This fixes the lossy flat answer, and makes it canonical: North/Mar is `None` while the user sees it under the `Upper` header row. It still needs either a second layout or an attribute carried per item. The second of those is B's cost with a worse answer, so I recommend rejecting C.\n\n### D. Keep flat membership and choose which property to lose\n\nFor example, go back to pre-#200 behaviour: `[Upper, GroupEnd, North/Mar]`, with membership stable and a header drawn over nothing. This is the cheapest option, and the ticket's enumeration shows flat membership cannot make both properties hold (274,584 cases against 45,806 at the two ends). Only acceptable as a documented leftover.\n\n## Recommendation\n\n**A.** It is the smallest type change that makes pruning on and off agree while also giving the answer that is correct at every level. It keeps h0iv's identity rule and the windowing payload bound. It also gives the browser what it would need to name a group's category for `z`.\n\nIf the user would rather pay for the invariant, B is the right shape. In that case I would take B in core first and put A's levelled markers on the wire: the host derives them, so the wire does not pay for O(items × levels).\n\n## Questions for the user\n\n1. **Which group does a row belong to?** Under A (and B), North/Mar is in `Upper`: `z` there collapses `Upper`, and the column band shows `Upper` over it. Today, unpruned, `z` there does nothing and the band is blank. Is the per-level answer the one you want?\n2. **What does `z` toggle when a row is in more than one open group?** I propose the innermost, which matches what `z` does today on North/Jan (`Q1`).\n3. **Is one column band enough?** A still paints one group per column (the innermost). One band per level would be a separate feature; I'd file it rather than fold it into this.\n\n## Proposed evidence for the fix\n\n- A property test over 2- and 3-level axes × collapse state × prune masks, the ticket's own enumeration, now as an assertion:\n - for every item that survives pruning, the membership stack of the pruned layout equals the unpruned layout's;\n - every header that is kept has at least one member.\n - Rival: today's flat `prune_axis_items`. It should fail on the ticket's 45,806-case shape. A pre-#200 rival should fail the no-empty-header half instead.\n- A frame-agreement test: `AxisFrame::groups()` on the projected frame equals `GridLayout`'s per-item membership, including a window that opens inside `North/Q1`. That is the per-level windowing rule, and a nearest-single-header rival must fail it.\n","created_at":"2026-09-23T16:01:42Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-bf0q","title":"bd setup factory calls #201's minimal Beads block stale at once and swaps in profile:full; bd -C is ignored by bd setup","description":"Found reviewing PR #201 (head 31b5acdb), in a scratch copy of the tree with bd 1.3.0.\n\n1. `bd setup factory --check` reports the block #201 installs (`profile:minimal hash:46cd31e7`) as **stale** straight away, not only \"whenever bd's template has moved on\" as #201's new \"The Beads blocks are bd's\" section says. Running `bd setup factory` then replaces it with `profile:full hash:bacef91e`. That render is much longer, uses `--json` everywhere, and prescribes `git pull --rebase`. After the rewrite `--check` reports \"(current)\". So the first person to follow bd's own advice flips the profile. The repo-owned section outside the markers survives, verified byte for byte, and it already names the full profile's `git pull --rebase`. The policy therefore holds, but the sentence about when bd calls the block stale should say it happens at once for factory.\n\n2. **`bd -C \u003cdir\u003e setup factory` ignores `-C`** and writes `AGENTS.md` in the current working directory. Run from an agent's worktree with `-C /tmp/scratch`, it rewrote the worktree's `AGENTS.md` and left the scratch copy untouched, and `--check` read the worktree too. Anyone told to \"verify bd's regeneration on a scratch copy\" must `cd` into the copy and must not trust `-C`. This is an upstream bd bug, recorded here because the scratch-copy instruction will be given again.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T05:53:27Z","dependencies":[{"issue_id":"improvise-bf0q","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-bf0q","depends_on_id":"improvise-x1uk","type":"discovered-from","created_at":"2026-09-23T05:53:27Z","created_by":"agent-review-201-202","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qnpi","title":"pr-reviews-test 'an inline comment arriving mid-wait is seen' is flaky under load (failed 1 of 2 identical runs)","description":"`./scripts/checkers.sh` failed one case in pr-reviews-test on the first run and passed on the second, with the same tree and no changes in between:\n\n FAIL an inline comment arriving mid-wait is seen (exit 1, wanted 0)\n pr-reviews-test: 21 passed, 1 failed\n\nThe tree was PR #202 head feefe17d, which touches no script. The machine was Edward's Mac (darwin), running while several cargo builds were in flight in the same session. The case name says it depends on timing (\"mid-wait\"), so it looks load-sensitive.\n\nIt is in the `kb` CI job (`checkers.sh ... pr-reviews`) and in the agent gate list. A flaky gate either teaches agents to re-run until green or blocks a PR on noise. Next step: run the suite in a loop under load, find the sleep or poll interval the case races against, and make it wait on a condition rather than on time.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:24Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T14:39:39Z","started_at":"2026-09-23T06:18:03Z","closed_at":"2026-09-23T14:39:39Z","close_reason":"Fixed in PR #212, merged after adversarial review at 1550d39. Follow-ups: improvise-7epn, improvise-lnsh.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8haj","title":"Dead-ID audit scans gitignored build output: after harness/build.sh it reports improvise-design/eval/key from harness/serve","description":"The dead-ID audit in AGENTS.md (\"Cite a ticket ID you have read back\") runs `grep -r` over `web` and `harness`, so it also scans gitignored build output: `harness/serve/`, `web/pkg/`, `web/worker-pkg/`, `harness/opfs-contract/target/` and `harness/node_modules/`.\n\nReproduced at PR #201 head 31b5acdb. After `./harness/build.sh`, which #201's agent-rules Gates block now tells agents to run whenever they touch web/ or harness/, the recipe exactly as written printed:\n\n Issue improvise-design not found\n Issue improvise-eval not found\n Issue improvise-key not found\n\nAll three come only from `harness/serve/docs/{test-evidence/gp,code/clients,design-principles}.html`, which is rendered prose. `git grep` finds none of them in tracked files. After deleting `harness/serve` the audit read \"all resolve\", and it still caught an id injected into `.agents/skills/wave/SKILL.md`.\n\nThis is the hazard #201 already argues about for `.claude/worktrees` (\"named file by file, never whole\"). It applies equally to build output under the scanned roots. A false dead id reads as a real one, so it costs whoever sees it a hunt through bd.\n\nSuggested fix: `git grep -ohE 'improvise-[a-z0-9]{3,8}\\b' -- \u003csame paths\u003e`. This scans only tracked files, so it is independent of build state and of `.claude/worktrees/`. git grep does not follow tracked symlinks, so the skills are still scanned once, through `.agents`. Verify by injection before landing it.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:53:21Z","created_by":"agent-review-201-202","updated_at":"2026-09-23T06:29:42Z","closed_at":"2026-09-23T06:29:42Z","close_reason":"Fixed in PR #201, merged after adversarial review (31b5acd, then the delta at 141be9f). Follow-ups: improvise-28ri, improvise-oga4, improvise-bf0q.","dependencies":[{"issue_id":"improvise-8haj","depends_on_id":"improvise-anmm","type":"discovered-from","created_at":"2026-09-23T05:53:21Z","created_by":"agent-review-201-202","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qz9p","title":"A drill commit into a category removed mid-drill refuses and still writes an orphan cell","description":"Found by adversarial review of PR #204 at 92eefeec. This predates #204 and is not introduced by it. While a drill is open, another client's RemoveCategory can remove a category that the drill has a staged rename for. On commit, `effect::AddItem` refuses with \"Unknown category 'Type'\" (`last_batch_refused` is true). The rewrite's clears and sets still run afterwards, so the store gets a cell keyed `Type=Drink` for a category the model no longer has. That is an orphan that no grid shows. Probe: two_record_drill(); `workbook.remove_category(\"Type\")`; stage (1, Type, \"Drink\") and (1, Cost, \"999\"); ApplyAndClearDrill. The store is then `{Drink/Jan/Cost = 999}`. The base code wrote the same orphan at the old key (Clothing/Jan/Cost) and discarded add_item's error. #204 only makes the refusal visible. Decide whether a failed AddItem should drop the rewrite's writes for that row.\n","status":"in_progress","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:50:00Z","created_by":"agent-review-204","updated_at":"2026-09-23T06:35:47Z","started_at":"2026-09-23T06:35:47Z","lease_expires_at":"2026-09-23T06:40:47Z","heartbeat_at":"2026-09-23T06:35:47Z","labels":["adversarial-review","pr-204"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qz9p","title":"A drill commit into a category removed mid-drill refuses and still writes an orphan cell","description":"Found by adversarial review of PR #204 at 92eefeec. This predates #204 and is not introduced by it. While a drill is open, another client's RemoveCategory can remove a category that the drill has a staged rename for. On commit, `effect::AddItem` refuses with \"Unknown category 'Type'\" (`last_batch_refused` is true). The rewrite's clears and sets still run afterwards, so the store gets a cell keyed `Type=Drink` for a category the model no longer has. That is an orphan that no grid shows. Probe: two_record_drill(); `workbook.remove_category(\"Type\")`; stage (1, Type, \"Drink\") and (1, Cost, \"999\"); ApplyAndClearDrill. The store is then `{Drink/Jan/Cost = 999}`. The base code wrote the same orphan at the old key (Clothing/Jan/Cost) and discarded add_item's error. #204 only makes the refusal visible. Decide whether a failed AddItem should drop the rewrite's writes for that row.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T05:50:00Z","created_by":"agent-review-204","updated_at":"2026-09-24T21:45:27Z","started_at":"2026-09-23T06:35:47Z","closed_at":"2026-09-24T21:45:27Z","close_reason":"Landed in main via #222 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-204"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v8ab","title":"No test pins a fresh session's Snapshot slice equal to the host's startup ViewState; lockstep.rs seeds from app.view_state and bypasses fresh_view","description":"Found reviewing PR #206 (improvise-lxg0). The fresh slice is constructed twice: App::with_storage builds ViewState{active_view, ..default()} and project.rs::fresh_view builds it again. Nothing asserts they agree. tests/lockstep.rs seeds every client with app.view_state.clone(), never with a Snapshot from Sessions::connect, so it structurally could not see lxg0's phantom back-frame and cannot see the next drift between the two. Verified at b22f6e53: a scratch assert_eq!(sessions.connect(\u0026app,..).1.view, app.view_state) on a document opening on a non-Default view passes after the fix and fails with the old fold restored. Scope is connect only: a rebase deliberately resets the client's whole slice while install_workbook keeps the host's mode/panels, so equality is not the contract there. Proposal: one test asserting the connect equality on a fresh App, or have fresh_view and App::with_storage share one constructor so the equality holds by construction.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:49:45Z","created_by":"agent-review-206","updated_at":"2026-09-23T14:44:20Z","started_at":"2026-09-23T05:51:48Z","closed_at":"2026-09-23T14:44:20Z","close_reason":"Fixed in PR #207, merged after adversarial review at 13b998d; the later change was an evidence-only merge plus a wording fix. Follow-up: improvise-y5eo (#216).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4img","title":"PR #188's clear variant is refused as RecordMoved, not RecordGone as its evidence reasons; it has no test","description":"Found re-reviewing PR #188 at head a5fbd84f. context/test-evidence/clients.md, under an_edit_is_refused_when_the_cell_was_written_while_the_editor_was_open, says the clear variant (B clears the cell while A's editor is open) is RecordGone, and that this is reasoning, not a test. context/code/clients.md lists 'the record is gone: RecordGone' as one of the outcomes.\n\nRAN it with a scratch test against a real Host (not committed). The fixture is the one browser_round_trip uses, with A's editor on North/Revenue at version 0. B sends ClearCell. The record still resolves, because App::resolve_record falls back to the layout and North is still a row, and its counter is bumped to 1. A's commit is refused as Rejected(RecordMoved(North)), and North stays empty. So the property holds: the cell is not re-created. Only the stated reason was wrong. The test DISCRIMINATES: with the rival (claim at commit), the same scenario is accepted and the cell is re-created at 42. Proposed: add the test as an_edit_is_refused_when_the_cell_was_cleared_while_the_editor_was_open, asserting RecordMoved and an empty cell, and correct both context paragraphs. RecordGone is reached by RemoveItem or RemoveCategory while the editor is open, both also run and refused as RecordGone.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:42:13Z","created_by":"agent-review-188b","updated_at":"2026-09-23T06:35:13Z","started_at":"2026-09-23T05:50:58Z","closed_at":"2026-09-23T06:35:13Z","close_reason":"Fixed in PR #188, merged as de70373 after adversarial re-review at a5fbd84. au6c and 4img were the review-requested fixes; later merges touched evidence only. Follow-up: improvise-vndi.","labels":["adversarial-review","evidence","pr-188","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-b9f2","title":"Determinism law's width guard measures the argument list, not the drills the comparison built","description":"Found in adversarial re-review of PR #187 (improvise-hzjo / improvise-e1sm) at head 0825f998.\n\nThe width guard in `every_command_gives_equal_effects_in_independently_built_equal_contexts` (`widest_drill`) measures how many records the *argument lists* match through `matching_cells`. It does not measure how many records a *built command* actually ordered in the effects the comparison compared. So it is a parallel derivation. It can pass while the comparison has lost its wide drills.\n\nRival (run, not argued): with gotcha 18 re-injected (`positions.sort_unstable()` in `DataStore::matching_positions` commented out), change the `drill-into-cell` parser in `registry.rs` from `if args.is_empty()` to `if args.len() \u003c 2`. The one-coordinate drills are then refused by `parse`, and the registry's cursor fallback builds them as a one-record cursor drill. `widest_drill` still reports 4 and the guard is silent. The law then **passed 38 of 300 runs** (~13%), the same as the pre-e1sm rate. Without the parser rival it failed 300 of 300.\n\nSo two statements are false as written:\n- `context/test-evidence/command.md`: \"The width guard reads the same argument list the comparison uses, so it cannot pass while the comparison has lost its wide drills.\"\n- `context/code/command.md`: \"That guard reads the argument list the law uses, not a copy of it, so dropping the derived drills fails it\".\n\nBoth are true for the one rival that was tried (dropping the `extend` line). They are false for a rival that keeps the list but changes what gets built from it.\n\nSuggested fix: measure the width from what was compared. For example, have `compare` record the largest `StartDrill` record count among the drill-into-cell effects it actually compared, and assert on that. Or assert that `drill-into-cell` *built from* a one-coordinate argument yields at least 4 records. Also consider `drill_constraint`, which the guard skips and the command applies.\n\nMinor, same area: `command.md` names \"the analyzer's `HashSet`\" as an instance of the nondeterminism the law is about. `analyze_records` is reached only from `Effect::apply` (`ImportJsonHeadless`, effect.rs) and from the wizard, so the law cannot see it. Injected: a `HashSet`-iteration `distinct_vec` passed the law 100 of 100 runs. Only `the_distinct_values_are_in_first_seen_order` caught it. The execute/apply scope paragraph covers this implicitly, but that sentence should say the analyzer is out of scope.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-hzjo2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:41:06Z","created_by":"agent-review-187b","updated_at":"2026-09-23T14:56:22Z","started_at":"2026-09-23T05:47:55Z","closed_at":"2026-09-23T14:56:22Z","close_reason":"Fixed in PR #208, merged as bf2de49. Reviewed at 7dfa90f; the btyw delta was the reviewer's verified one-line repair, re-measured by the author.","dependencies":[{"issue_id":"improvise-b9f2","depends_on_id":"improvise-e1sm","type":"discovered-from","created_at":"2026-09-23T05:41:12Z","created_by":"agent-review-187b","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-diho","title":"agent-rules: pin a resolved SHA, never the gh/main ref name, when resetting or diffing mid-task","description":"Remote refs are shared across worktrees, so gh/main moves under an agent whenever any other session fetches. agent-uo2p ran 'git reset --soft gh/main', which staged a reverse diff of #196. It noticed only because it read git status. Rule for .agents/skills/wave/agent-rules.md: at task start, resolve gh/main to a SHA and use that literal SHA for reset, merge-base and diff; re-resolve on purpose when you mean to merge newer main.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T05:35:46Z","created_by":"orchestrator","updated_at":"2026-09-23T15:15:50Z","started_at":"2026-09-23T06:30:19Z","closed_at":"2026-09-23T15:15:50Z","close_reason":"Fixed in PR #211, merged as 968f65a after adversarial review (delta at 41aa407).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kndq","title":"A records rename onto an existing record silently overwrites it (plain records mode and drill alike)","description":"Surfaced by improvise-uo2p (PR #204). When two drill rows are renamed onto the same coordinates, or a rename lands on a record another row is editing, the later snapshot row wins cell by cell and the earlier row's values are lost with no notice. Plain records mode already does this when a rename lands on an existing record, and #204 kept that behaviour deliberately so the two paths agree. Decide whether a rename that collides should be refused (in both paths, via RecordRewrite), or should merge, and say which.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:35:36Z","created_by":"orchestrator","updated_at":"2026-09-23T05:35:36Z","comments":[{"id":"fdf83794-0448-57bd-ad9f-13126012d8e6","issue_id":"improvise-kndq","author":"agent-review-204","text":"From the adversarial review of PR #204 (at 92eefeec): the rule that ships is \"later row wins **per cell, among rows that write**\". It is not \"the later row wins\". I measured it with probes on two_record_drill:\n- An unedited row does not write. So when row0 is renamed onto row1's coordinates, row0's values overwrite row1's, even though row1 is the later row. Plain mode does the same.\n- A no-op rename (row1 Type Clothing-\u003eClothing) makes row1 write, which flips the winner to row1. Pressing Enter on an unchanged cell changes which record survives.\n- The rows can carry different measures. With Food{Revenue=1, Cost=2} and Clothing{Revenue=42} both renamed to Drink, the result is Drink{Revenue=42, Cost=2}, a chimera of two records.\n- A blank edit on a measure the row HAS clears the source and leaves the destination's value. A blank edit on a measure the row LACKS clears the destination key, which can belong to a record outside the drill.\nAll of these agree with plain records mode as far as it can express them. Whatever policy this ticket picks has to answer these four cases.\n","created_at":"2026-09-23T05:50:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-80vh","title":"Stale watchdog prose: 'the worker has no error that fires' / 'saying in-page worker forever' / setShellFailure","description":"Found reviewing PR #199 at d5854bc1. Three stale copies of the watchdog argument. (1) context/code/clients.md 'The watchdog, and the two close arms' (~line 1690) says the worker has no failure event that fires, so a browser with no OPFS 'would leave the page saying \"in-page worker\" over an empty grid forever'. Since improvise-1wft a start() rejection posts a BootFailed notice with its reason, and since #199 the pre-frame text is 'starting the worker · \u003cdoc\u003e…'. The watchdog now guards only a peer that never answers AND never says why (a hang), not a start() rejection. (2) context/repo-map.md gotcha 46 carries the same claim ('A worker that rejects in start() fires no error on the main thread — worker.js catches its own — so the guard is a first-frame watchdog'): the first half is still literally true, the 'so' is not, since 1wft's notice is the guard for that case. (3) clients.md ~1701 and ~1745, repo-map gotcha 48, and test-evidence/clients.md ~697-707 name setShellFailure, which no longer exists: the function is createShell().fail in web/shell.js. repo-map is orchestrator-only; clients.md and test-evidence are not.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:21Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:21Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a3dj","title":"A frame that arrives after the handshake watchdog fired leaves 'did not answer' over a working grid","description":"Found reviewing PR #199 (improvise-i0v9) at d5854bc1. createShell().sawFrame writes the success text only while the watchdog is still armed, which stands in for 'nothing has spoken since arming'. But the watchdog's OWN firing also disarms it, so a peer that answers after 8s (a slow cold wasm compile, a slow OPFS open) paints a working grid under 'the in-page worker did not answer (see console)' permanently. Probe: arm(msg,{answer}); fire the timer; sawFrame() -\u003e seen = [msg] only. Socket path is the same: 'never answered' outlives a late Snapshot. Pre-existing (before #199 sawFrame wrote nothing and the watchdog had already replaced 'in-page worker'), so not a regression of #199, but #199's rule 'success is claimed by the first frame, and only if nothing failed first' is violated by its proxy: the watchdog is a GUESS that a frame falsifies, whereas a loud failure (fail) is a fact a frame does not. Fix shape: a flag set by fail() (not by the timer) gates the claim, and a late first frame replaces the watchdog's text (with answer, or with the socket's connected text). Test: unit test in web/tests/shell.test.js arming, firing, then sawFrame, asserting the last write is the answer; plus the fail-then-frame case must still keep the failure.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:09Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:09Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kndq","title":"A records rename onto an existing record silently overwrites it (plain records mode and drill alike)","description":"Surfaced by improvise-uo2p (PR #204). When two drill rows are renamed onto the same coordinates, or a rename lands on a record another row is editing, the later snapshot row wins cell by cell and the earlier row's values are lost with no notice. Plain records mode already does this when a rename lands on an existing record, and #204 kept that behaviour deliberately so the two paths agree. Decide whether a rename that collides should be refused (in both paths, via RecordRewrite), or should merge, and say which.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:35:36Z","created_by":"orchestrator","updated_at":"2026-09-23T05:35:36Z","dependencies":[{"issue_id":"improvise-kndq","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:24Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"fdf83794-0448-57bd-ad9f-13126012d8e6","issue_id":"improvise-kndq","author":"agent-review-204","text":"From the adversarial review of PR #204 (at 92eefeec): the rule that ships is \"later row wins **per cell, among rows that write**\". It is not \"the later row wins\". I measured it with probes on two_record_drill:\n- An unedited row does not write. So when row0 is renamed onto row1's coordinates, row0's values overwrite row1's, even though row1 is the later row. Plain mode does the same.\n- A no-op rename (row1 Type Clothing-\u003eClothing) makes row1 write, which flips the winner to row1. Pressing Enter on an unchanged cell changes which record survives.\n- The rows can carry different measures. With Food{Revenue=1, Cost=2} and Clothing{Revenue=42} both renamed to Drink, the result is Drink{Revenue=42, Cost=2}, a chimera of two records.\n- A blank edit on a measure the row HAS clears the source and leaves the destination's value. A blank edit on a measure the row LACKS clears the destination key, which can belong to a record outside the drill.\nAll of these agree with plain records mode as far as it can express them. Whatever policy this ticket picks has to answer these four cases.\n","created_at":"2026-09-23T05:50:08Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-80vh","title":"Stale watchdog prose: 'the worker has no error that fires' / 'saying in-page worker forever' / setShellFailure","description":"Found reviewing PR #199 at d5854bc1. Three stale copies of the watchdog argument. (1) context/code/clients.md 'The watchdog, and the two close arms' (~line 1690) says the worker has no failure event that fires, so a browser with no OPFS 'would leave the page saying \"in-page worker\" over an empty grid forever'. Since improvise-1wft a start() rejection posts a BootFailed notice with its reason, and since #199 the pre-frame text is 'starting the worker · \u003cdoc\u003e…'. The watchdog now guards only a peer that never answers AND never says why (a hang), not a start() rejection. (2) context/repo-map.md gotcha 46 carries the same claim ('A worker that rejects in start() fires no error on the main thread — worker.js catches its own — so the guard is a first-frame watchdog'): the first half is still literally true, the 'so' is not, since 1wft's notice is the guard for that case. (3) clients.md ~1701 and ~1745, repo-map gotcha 48, and test-evidence/clients.md ~697-707 name setShellFailure, which no longer exists: the function is createShell().fail in web/shell.js. repo-map is orchestrator-only; clients.md and test-evidence are not.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:21Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:21Z","dependencies":[{"issue_id":"improvise-80vh","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a3dj","title":"A frame that arrives after the handshake watchdog fired leaves 'did not answer' over a working grid","description":"Found reviewing PR #199 (improvise-i0v9) at d5854bc1. createShell().sawFrame writes the success text only while the watchdog is still armed, which stands in for 'nothing has spoken since arming'. But the watchdog's OWN firing also disarms it, so a peer that answers after 8s (a slow cold wasm compile, a slow OPFS open) paints a working grid under 'the in-page worker did not answer (see console)' permanently. Probe: arm(msg,{answer}); fire the timer; sawFrame() -\u003e seen = [msg] only. Socket path is the same: 'never answered' outlives a late Snapshot. Pre-existing (before #199 sawFrame wrote nothing and the watchdog had already replaced 'in-page worker'), so not a regression of #199, but #199's rule 'success is claimed by the first frame, and only if nothing failed first' is violated by its proxy: the watchdog is a GUESS that a frame falsifies, whereas a loud failure (fail) is a fact a frame does not. Fix shape: a flag set by fail() (not by the timer) gates the claim, and a late first frame replaces the watchdog's text (with answer, or with the socket's connected text). Test: unit test in web/tests/shell.test.js arming, firing, then sawFrame, asserting the last write is the answer; plus the fail-then-frame case must still keep the failure.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:22:09Z","created_by":"agent-review-199","updated_at":"2026-09-23T05:22:09Z","dependencies":[{"issue_id":"improvise-a3dj","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-aszt","title":"every_keys_row_drawn_is_one_the_builders_asked_for compares counts, so a self::keys bypass that also drops a real row passes the workspace","description":"Found in adversarial review of PR #198 at head 8772db2e (the improvise-ssoe fix).\n\nssoe made KeyRow::looked_up private and added help::tests::every_keys_row_drawn_is_one_the_builders_asked_for, which asserts COUNT(drawn HelpLine::Keys) == help_bindings().len(). The author named the residual: a builder can still call the module-level keys by path (self::keys) instead of its shadowing parameter, and argued the count catches it.\n\nIt does not when the bypass also drops one real row, so both sides move together. Injected in editing(), replacing the Editing-mode clear-buffer row with a block that (1) calls the parameter keys(e, \"clear-buffer\", Some(\u0026[\"edit\"]), \"Cancel edit, return to Normal\") and discards the result with let _ =, then (2) returns self::keys(n, \"clear-cell\", None, \"Edit the current cell\").\n\nThe parameter call is recorded and discarded; the bypass draws \"x Edit the current cell\" (the lie ssoe's own evidence names) unrecorded. cargo test --workspace --no-fail-fast: WHOLE WORKSPACE GREEN (improvise-protocol recompiled; the count test reported ok). The same injection on the edit-or-drill row is caught, but only by that row's dedicated test (the_edit_key_row_says_it_drills_into_an_aggregate).\n\nFix: compare CONTENT, not count. Walk the drawn Keys rows in order and assert they equal help_bindings() looked up in order (description and keys). A bypass that draws exactly what the builders asked for is then the only thing that passes, and that one is harmless.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:51Z","created_by":"Edward Langley","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:39:26Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","labels":["help","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1c5m","title":"Browser help overlay covers #hint once the app bar wraps, and Welcome no longer says how to page (PR #180 removed the tip)","description":"Found in adversarial review of PR #180 at head f2488276 (delta 7935b00, which removed the Welcome page's \"Tip: press Tab or l/n to go to the next page\" rather than deriving it, arguing Help mode's status hint already derives next-page).\n\nMEASURED (Playwright, chromium, harness tree built at f2488276, `?` from the grid, Welcome active):\n- 1280x800: #hint at y=9, text \"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close\", 208px wide, elementFromPoint at the hint = #hint. Visible.\n- 800x800: the header has wrapped, #hint is at y=43 (363px wide), #help's top edge is 32px (inset: 2rem, z-index 30). elementFromPoint at the hint = #help. COVERED.\n- 600x800: #hint at y=43, 27px wide, elementFromPoint = .help-tab. COVERED.\n\nSo below the width at which the app bar wraps, the only paging instruction the browser shows on Welcome is under the overlay, and the .help-tab spans have no click handler. Before 7935b00 the Welcome body itself carried the instruction. The TUI is unaffected: its popup leaves the status row visible and its own footer spells l/Tab.\n\nRelated but distinct: improvise-rx8g (#hint illegible at phone width) is about the hint's width; this is the overlay covering it even when it has width.\n\nOptions: (a) a derived PROSE row on Welcome (keys off keys_for(ModeKey::Help, \"help-page-next\"), rendered as text, so help.spec.js's \"Welcome is prose\" still holds); (b) the browser overlay draws hint_line(Help) in its own footer, as the TUI popup does; (c) make .help-tab clickable. (a) or (b) keeps one derived table.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T14:59:03Z","started_at":"2026-09-23T06:37:35Z","closed_at":"2026-09-23T14:59:03Z","close_reason":"Fixed in PR #215, merged after adversarial review at a8737f3. Follow-up: improvise-mv1d.","labels":["browser","help","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mkbp","title":"harness/build.sh rewrites harness/opfs-contract/Cargo.lock on every run (pest 2.9.1 -\u003e 2.9.2)","description":"Reported by agent-i0v9 and by the #180 author: every harness build dirties harness/opfs-contract/Cargo.lock, so each agent reverts it by hand, and autostash churns during merges. Either commit the resolved lock, or build with --locked so the drift fails loudly instead. Separately: re-running only web/assemble.sh wipes harness/serve/opfs, after which opfs.spec.js fails 8 times until build.sh's tail steps are run again. Document it or make assemble.sh preserve the directory.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:15:58Z","created_by":"orchestrator","updated_at":"2026-09-23T14:39:39Z","started_at":"2026-09-23T06:18:01Z","closed_at":"2026-09-23T14:39:39Z","close_reason":"Fixed in PR #212, merged after adversarial review at 1550d39. Follow-ups: improvise-7epn, improvise-lnsh.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z4up","title":"source_scan tests do not discriminate not(Unknown): rival not(Unknown)=No hides cfg(not(target_arch)) code with all 7 tests green","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6).\n\n## The gap\n\n`source_scan::combine`'s `not` arm maps `Unknown` to `Unknown`. That is the direction which keeps code in production. Injecting the rival `not(Unknown) =\u003e No` (in the `_ =\u003e Truth::Unknown` arm under `\"not\"`) leaves **all 7** source_scan tests green after a real rebuild. Under that rival, `#[cfg(not(target_arch = \"wasm32\"))]` and `#[cfg(not(feature = \"x\"))]` items are classified test-only, and so hidden from both vocabulary guards.\n\nThe existing cases cannot see it:\n- `all(test, not(target_arch = ..))` is `all(No, _)`, which is `No` whatever `not` returns.\n- The controls are `not(test)`, `any(test, feature = \"serde\")` and a bare `target_arch`. None of them puts an unknown predicate under `not`.\n\nNo `cfg(not(\u003cnon-test\u003e))` exists in crates/*/src or src/ today, so it is latent.\n\n## Fix\n\nAdd a control to `a_test_only_cfg_is_recognised_whatever_its_spelling`: `#[cfg(not(target_arch = \"wasm32\"))]` must stay production. Then confirm that the rival above turns it red.\n\nChecked and held in the same review:\n- `feature = \"test-support\" =\u003e Unknown` is caught by `test_only_items_and_modules_are_not_production` (the `gated` assertion).\n- Dropping `c` from the raw-string arm is caught by `a_c_string_literal_is_blanked`.\n- Dropping `c` from the plain C-string arm passes every test, but is observationally equivalent: the `\"` is then lexed as an ordinary string with the same escapes.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:27Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-z4up","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-yqnp","title":"source_scan hides later siblings after a comma-separated test-only element (match arm, vec!, struct field, enum variant) and after a block followed by an else-prefixed identifier","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). It is latent: the new scanner's output is token-identical to the old one on every .rs file in the workspace. But it contradicts the PR's stated property, that a test-only item hides nothing after it and that an item which cannot be bounded is a panic.\n\n## The shape\n\n`item_end` (crates/improvise-protocol/src/source_scan.rs) knows only two terminators: a `;` at depth 0, and the `}` closing a body. A test-only element that is separated from its siblings by a **comma** has neither. So the scan runs on to the bracket that closes the enclosing list, and the `')' | ']' | '}' if depth == 0 =\u003e return Some(k)` arm accepts that bracket as \"the item was the last expression\". Every later sibling is dropped, silently, with no panic.\n\nReproduced through `production_text`, with the output after `--- new ---`:\n\n1. **Match arm.**\n ```\n match k {\n #[cfg(test)]\n K::T =\u003e Command::TestArm(0),\n K::P =\u003e Command::ProdArm1(1),\n }\n ```\n Output: `match k { }`. So `Command::ProdArm1` is invisible to `vocabulary_is_composed`.\n2. **vec!/array element.** `vec![ #[cfg(test)] Command::TestElem(0), Command::ProdElem(1), ]` gives `vec![ ]`.\n3. **Struct-literal field.** `S { #[cfg(test)] t: .., p: Command::ProdField(1), }` gives `S { }`.\n4. **Enum variants and struct fields** behave the same: every later variant or field is dropped.\n\nSecond shape, same class. `continues()` tests `rest.starts_with(\"else\")` with no word boundary. So after a test-only block statement:\n```\n#[cfg(test)]\n{ setup(); }\nelsewhere(Command::ProdAfterBlock(1));\n```\nThe production statement `elsewhere(...)` is swallowed up to its `;`.\n\nThe old line scanner was worse on all of these: it hid to EOF. So this is not a regression; it is an unclosed part of 59q8's class.\n\n## Suggested fix\n\nRefuse rather than guess. If `item_end` reaches the enclosing close bracket having crossed a depth-0 `,`, panic with \"could not be bounded\". A plain `,` terminator would mis-bound generics such as `impl\u003cA, B\u003e` and `where A: X, B: Y`. Give `else` a word boundary.\n\nA test belongs in `a_test_only_item_hides_nothing_after_it` for each shape. Each fixture here was run under a scratch harness and then deleted.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:20Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-yqnp","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3m7b","title":"No test pins that yank and drill-into-cell with explicit coordinates act on them rather than the cursor","description":"Found in adversarial review of PR #182 (improvise-yf7p) at head 0a6e940, merged locally with gh/main da2e5ab.\n\n#171's review showed that a registry `contextualize` taking yank, paste and drill-into-cell's key from the cursor, over the parsed one, left the workspace green. #182 removes the registry as a place that bug can be written: `contextualize` is gone, and an `AtCursor` impl pointed at `key` is E0308, which I tried. But the same bug written in `execute` is still unguarded for two of the three commands.\n\n- `YankCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `DrillIntoCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `PasteCell` with the same injection fails `paste_with_an_explicit_full_key_writes_even_where_the_view_aggregates` and `paste_with_yanked_value_produces_set_cell`. Guarded.\n\nPre-existing, not introduced by #182. Wanted: for `yank` and `drill-into-cell`, a test that runs the command with explicit coordinates while the cursor sits on a different cell, both at nonzero positions, and asserts the effects name the explicit cell.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:10:00Z","created_by":"agent-review-182","updated_at":"2026-09-23T05:10:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pakc","title":"The totals strip projects id: None, which the contract reserves for 'no cell' (exdg's sibling, needs its own decision)","description":"Found by agent-viewhist while fixing improvise-exdg (PR #196). The wasm client's render.rs::totals_strip projects every totals cell with id: None, which is the same conflation exdg fixed for records coordinate cells. exdg's fix can't be copied: a column total over a formula-owned column (e.g. Profit) is a SUM of that column, not the formula, so it is neither Aggregate in the ADR 002 sense nor Derived. Decide what identity a total carries, if any, and write it into ADR 002 alongside the fifth kind (Coordinate) that #196 added.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:06:15Z","created_by":"orchestrator","updated_at":"2026-09-23T05:06:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qvpu","title":"pr-watch: a BLOCKED timeout should name the required checks that never registered","description":"Found re-reviewing PR #186 at 04230a7. Since improvise-zlj2, green + MERGEABLE/BLOCKED is waited on, so a required check that never registers (e.g. a CI job renamed so the ruleset's context no longer matches) now costs the default 1800s and exits 3 with only 'mergeability MERGEABLE/BLOCKED'; before zlj2 it exited 1 at once. Checked this repo's rules (gh api repos/fiddlerwoaroof/improvise/rules/branches/main, 2026-09-22): branch protection API 404s; one ruleset requires 3 checks (not strict), 0 approvals, no stale-review dismissal, no thread resolution. So review-required / stale-review BLOCKED cannot occur here, but the ruleset also carries require_extra_approval_for_unattributed_changes: true, whose trigger is unverified and which would present as exactly this timeout. Waiting is the right call; the gap is diagnosis. Suggested: on a BLOCKED timeout, diff the ruleset's required_status_checks contexts against the rollup names and print the missing ones (or print 'no required check missing' so an approval/rule block is distinguishable).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:08Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:04:08Z","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1qaa","title":"pr-watch waits the full timeout on a MERGED or CLOSED PR, which GitHub reports as UNKNOWN/UNKNOWN","description":"Found re-reviewing PR #186 at 04230a7. Live: gh pr view 189 --json mergeable,mergeStateStatus on the merged PR returns UNKNOWN/UNKNOWN with every check green. Since improvise-edpl UNKNOWN is waited on, so pr-watch on an already-merged (or closed) PR sleeps the default 1800s and exits 3 'mergeability UNKNOWN/UNKNOWN'. Fails closed (no false pass), but under merge-as-soon-as-green a watch on a PR that another actor just merged costs 30 minutes and names the wrong reason. The script never fetches .state. Fix: add state to the --json field list and end early on MERGED/CLOSED with a distinct message (exit code to be decided; not 0, since 0 means mergeable).","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:07Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:04:07Z","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9pfs","title":"pr-watch-test has no case for a failed check under MERGEABLE/BLOCKED, the state a failed required check reports","description":"Found re-reviewing PR #186 at 04230a7. Every red fixture in scripts/pr-watch-test.sh wears UNSTABLE or UNKNOWN, but in this repo a failed REQUIRED check reports MERGEABLE/BLOCKED. Rival: gate the failed-check break with state != BLOCKED (if { [ \"$failed\" -gt 0 ] \u0026\u0026 [ \"$state\" != BLOCKED ]; } || ...). All 45 cases still pass; on a fixture of green build + FAILURE 'Test and lint (host)' + MERGEABLE/BLOCKED the head script exits 1 and the rival exits 3 after the full timeout. Not a false pass, but the commonest red in this repo is unpinned and a refactor of the wait could turn every red into a 30-minute timeout silently. Fix: add a case red rollup + MERGEABLE/BLOCKED expecting 1 at --timeout 0 (the table only enumerates green rollups).","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:03:54Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:03:54Z","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z4up","title":"source_scan tests do not discriminate not(Unknown): rival not(Unknown)=No hides cfg(not(target_arch)) code with all 7 tests green","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6).\n\n## The gap\n\n`source_scan::combine`'s `not` arm maps `Unknown` to `Unknown`. That is the direction which keeps code in production. Injecting the rival `not(Unknown) =\u003e No` (in the `_ =\u003e Truth::Unknown` arm under `\"not\"`) leaves **all 7** source_scan tests green after a real rebuild. Under that rival, `#[cfg(not(target_arch = \"wasm32\"))]` and `#[cfg(not(feature = \"x\"))]` items are classified test-only, and so hidden from both vocabulary guards.\n\nThe existing cases cannot see it:\n- `all(test, not(target_arch = ..))` is `all(No, _)`, which is `No` whatever `not` returns.\n- The controls are `not(test)`, `any(test, feature = \"serde\")` and a bare `target_arch`. None of them puts an unknown predicate under `not`.\n\nNo `cfg(not(\u003cnon-test\u003e))` exists in crates/*/src or src/ today, so it is latent.\n\n## Fix\n\nAdd a control to `a_test_only_cfg_is_recognised_whatever_its_spelling`: `#[cfg(not(target_arch = \"wasm32\"))]` must stay production. Then confirm that the rival above turns it red.\n\nChecked and held in the same review:\n- `feature = \"test-support\" =\u003e Unknown` is caught by `test_only_items_and_modules_are_not_production` (the `gated` assertion).\n- Dropping `c` from the raw-string arm is caught by `a_c_string_literal_is_blanked`.\n- Dropping `c` from the plain C-string arm passes every test, but is observationally equivalent: the `\"` is then lexed as an ordinary string with the same escapes.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:27Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-z4up","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-z4up","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:27Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-yqnp","title":"source_scan hides later siblings after a comma-separated test-only element (match arm, vec!, struct field, enum variant) and after a block followed by an else-prefixed identifier","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). It is latent: the new scanner's output is token-identical to the old one on every .rs file in the workspace. But it contradicts the PR's stated property, that a test-only item hides nothing after it and that an item which cannot be bounded is a panic.\n\n## The shape\n\n`item_end` (crates/improvise-protocol/src/source_scan.rs) knows only two terminators: a `;` at depth 0, and the `}` closing a body. A test-only element that is separated from its siblings by a **comma** has neither. So the scan runs on to the bracket that closes the enclosing list, and the `')' | ']' | '}' if depth == 0 =\u003e return Some(k)` arm accepts that bracket as \"the item was the last expression\". Every later sibling is dropped, silently, with no panic.\n\nReproduced through `production_text`, with the output after `--- new ---`:\n\n1. **Match arm.**\n ```\n match k {\n #[cfg(test)]\n K::T =\u003e Command::TestArm(0),\n K::P =\u003e Command::ProdArm1(1),\n }\n ```\n Output: `match k { }`. So `Command::ProdArm1` is invisible to `vocabulary_is_composed`.\n2. **vec!/array element.** `vec![ #[cfg(test)] Command::TestElem(0), Command::ProdElem(1), ]` gives `vec![ ]`.\n3. **Struct-literal field.** `S { #[cfg(test)] t: .., p: Command::ProdField(1), }` gives `S { }`.\n4. **Enum variants and struct fields** behave the same: every later variant or field is dropped.\n\nSecond shape, same class. `continues()` tests `rest.starts_with(\"else\")` with no word boundary. So after a test-only block statement:\n```\n#[cfg(test)]\n{ setup(); }\nelsewhere(Command::ProdAfterBlock(1));\n```\nThe production statement `elsewhere(...)` is swallowed up to its `;`.\n\nThe old line scanner was worse on all of these: it hid to EOF. So this is not a regression; it is an unclosed part of 59q8's class.\n\n## Suggested fix\n\nRefuse rather than guess. If `item_end` reaches the enclosing close bracket having crossed a depth-0 `,`, panic with \"could not be bounded\". A plain `,` terminator would mis-bound generics such as `impl\u003cA, B\u003e` and `where A: X, B: Y`. Give `else` a word boundary.\n\nA test belongs in `a_test_only_item_hides_nothing_after_it` for each shape. Each fixture here was run under a scratch harness and then deleted.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:20Z","labels":["adversarial-review","pr-195","protocol","testing"],"dependencies":[{"issue_id":"improvise-yqnp","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-yqnp","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:20Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-3m7b","title":"No test pins that yank and drill-into-cell with explicit coordinates act on them rather than the cursor","description":"Found in adversarial review of PR #182 (improvise-yf7p) at head 0a6e940, merged locally with gh/main da2e5ab.\n\n#171's review showed that a registry `contextualize` taking yank, paste and drill-into-cell's key from the cursor, over the parsed one, left the workspace green. #182 removes the registry as a place that bug can be written: `contextualize` is gone, and an `AtCursor` impl pointed at `key` is E0308, which I tried. But the same bug written in `execute` is still unguarded for two of the three commands.\n\n- `YankCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `DrillIntoCell::execute` using `ctx.cell_key()` in place of `self.key`: the **full workspace is green**.\n- `PasteCell` with the same injection fails `paste_with_an_explicit_full_key_writes_even_where_the_view_aggregates` and `paste_with_yanked_value_produces_set_cell`. Guarded.\n\nPre-existing, not introduced by #182. Wanted: for `yank` and `drill-into-cell`, a test that runs the command with explicit coordinates while the cursor sits on a different cell, both at nonzero positions, and asserts the effects name the explicit cell.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:10:00Z","created_by":"agent-review-182","updated_at":"2026-09-23T05:10:00Z","dependencies":[{"issue_id":"improvise-3m7b","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:14Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pakc","title":"The totals strip projects id: None, which the contract reserves for 'no cell' (exdg's sibling, needs its own decision)","description":"Found by agent-viewhist while fixing improvise-exdg (PR #196). The wasm client's render.rs::totals_strip projects every totals cell with id: None, which is the same conflation exdg fixed for records coordinate cells. exdg's fix can't be copied: a column total over a formula-owned column (e.g. Profit) is a SUM of that column, not the formula, so it is neither Aggregate in the ADR 002 sense nor Derived. Decide what identity a total carries, if any, and write it into ADR 002 alongside the fifth kind (Coordinate) that #196 added.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:06:15Z","created_by":"orchestrator","updated_at":"2026-09-23T05:06:15Z","dependencies":[{"issue_id":"improvise-pakc","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qvpu","title":"pr-watch: a BLOCKED timeout should name the required checks that never registered","description":"Found re-reviewing PR #186 at 04230a7. Since improvise-zlj2, green + MERGEABLE/BLOCKED is waited on, so a required check that never registers (e.g. a CI job renamed so the ruleset's context no longer matches) now costs the default 1800s and exits 3 with only 'mergeability MERGEABLE/BLOCKED'; before zlj2 it exited 1 at once. Checked this repo's rules (gh api repos/fiddlerwoaroof/improvise/rules/branches/main, 2026-09-22): branch protection API 404s; one ruleset requires 3 checks (not strict), 0 approvals, no stale-review dismissal, no thread resolution. So review-required / stale-review BLOCKED cannot occur here, but the ruleset also carries require_extra_approval_for_unattributed_changes: true, whose trigger is unverified and which would present as exactly this timeout. Waiting is the right call; the gap is diagnosis. Suggested: on a BLOCKED timeout, diff the ruleset's required_status_checks contexts against the rollup names and print the missing ones (or print 'no required check missing' so an approval/rule block is distinguishable).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:08Z","created_by":"agent-review-186b","updated_at":"2026-09-23T05:04:08Z","labels":["adversarial-review-pr-186"],"dependencies":[{"issue_id":"improvise-qvpu","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1qaa","title":"pr-watch waits the full timeout on a MERGED or CLOSED PR, which GitHub reports as UNKNOWN/UNKNOWN","description":"Found re-reviewing PR #186 at 04230a7. Live: gh pr view 189 --json mergeable,mergeStateStatus on the merged PR returns UNKNOWN/UNKNOWN with every check green. Since improvise-edpl UNKNOWN is waited on, so pr-watch on an already-merged (or closed) PR sleeps the default 1800s and exits 3 'mergeability UNKNOWN/UNKNOWN'. Fails closed (no false pass), but under merge-as-soon-as-green a watch on a PR that another actor just merged costs 30 minutes and names the wrong reason. The script never fetches .state. Fix: add state to the --json field list and end early on MERGED/CLOSED with a distinct message (exit code to be decided; not 0, since 0 means mergeable).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:07Z","created_by":"agent-review-186b","updated_at":"2026-09-24T21:45:58Z","closed_at":"2026-09-24T21:45:58Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9pfs","title":"pr-watch-test has no case for a failed check under MERGEABLE/BLOCKED, the state a failed required check reports","description":"Found re-reviewing PR #186 at 04230a7. Every red fixture in scripts/pr-watch-test.sh wears UNSTABLE or UNKNOWN, but in this repo a failed REQUIRED check reports MERGEABLE/BLOCKED. Rival: gate the failed-check break with state != BLOCKED (if { [ \"$failed\" -gt 0 ] \u0026\u0026 [ \"$state\" != BLOCKED ]; } || ...). All 45 cases still pass; on a fixture of green build + FAILURE 'Test and lint (host)' + MERGEABLE/BLOCKED the head script exits 1 and the rival exits 3 after the full timeout. Not a false pass, but the commonest red in this repo is unpinned and a refactor of the wait could turn every red into a 30-minute timeout silently. Fix: add a case red rollup + MERGEABLE/BLOCKED expecting 1 at --timeout 0 (the table only enumerates green rollups).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:03:54Z","created_by":"agent-review-186b","updated_at":"2026-09-24T21:45:58Z","closed_at":"2026-09-24T21:45:58Z","close_reason":"Landed in main via #253 (reviewed; merged 2026-09-24).","labels":["adversarial-review-pr-186"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-514o","title":"Five of ImportJsonHeadless's refuse_and_stop aborts fail no test when the abort is dropped","description":"Found by the adversarial review of PR #181 at head 12171fb.\n\n`refuse_and_stop` (refuse, plus `abort_effects`) is the per-site choice for LoadModel and the six ImportJsonHeadless arms. That abort is what keeps `import-json x . q` from quitting after an import that did nothing. I replaced `refuse_and_stop(app, …)` with `app.refuse(…)` one site at a time, which keeps the refusal and drops the abort, and ran `cargo test --workspace --no-fail-fast`:\n\n- LoadModel (effect.rs:971) fails `a_failed_load_or_import_does_not_run_the_quit_after_it`.\n- The JSON cannot-read arm (effect.rs:1047) fails the same test.\n- **Five arms fail nothing:** CSV cannot-read (1028), CSV parse error (1039), JSON parse error (1054), array-path error (1061), and import/build error (1100).\n\nThe wire table test `every_failing_model_command_refuses_its_whole_frame` cannot see the abort half, because the frame is rejected on `refused || aborted`, so the refusal alone suffices. Fix: extend the quit test's case list to the fixtures the table test already has (`absent.csv`, `bad.csv`, `bad.json`, `nested.json`+`nope`, `measures.json`).\n\nRelated, also untested: `Host::mutating_frame`'s `|| last_batch_aborted()` arm. Reading only `last_batch_refused()` passes every test. Today it is unreachable, because every wire-reachable abort also refuses and `AbortChain` is ViewOnly. It is harmless as a defensive arm, but it is unwitnessed.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T05:00:52Z","created_by":"agent-review-181","updated_at":"2026-09-23T14:56:21Z","started_at":"2026-09-23T05:16:54Z","closed_at":"2026-09-23T14:56:21Z","close_reason":"Fixed in PR #213, merged as 345a358 after adversarial review at 3929196. Follow-ups: improvise-we1a, improvise-rtud.","labels":["adversarial-review","pr-181"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oplp","title":"Tile select opens on _Index, so T then Enter (or T then r) empties a pivot grid, and the browser persists it at once","description":"Found reviewing PR #192 (improvise-jgzw), head f44767c9. That PR keeps _Index/_Dim tileable, correctly, and states the residual cost in context/code/command.md: 'one cycle key on tile 0 puts _Index on Row alone ... If that turns out to matter, the fix is the order the tile bar lists categories in'. This ticket is that residual, measured.\n\nEnterTileSelect sets SetTileCatIdx(0), and tile 0 is _Index because Model::category_names() lists the virtual categories first. In TileSelect, Enter and Space are cycle-axis-for-tile and r is set-axis-for-tile row. So two keystrokes, T Enter (Enter reading as 'confirm') or T r, put _Index on Row.\n\nCONFIRMED headless at f44767c9 on a copy of web/demo.improv: 'set-axis _Index row' then save writes '_Index: row' into the real Plan view, and export-csv of the reopened file is the header row only ('_Index/Product (Quarter=Q1),Revenue,Cost,Profit'), against four product rows before. The TUI only persists on an explicit save (autosave writes a side file), but the browser worker persists every accepted model command to OPFS (gotcha 45), so there the empty grid is the saved document immediately. Recovery exists (the tile bar, status line says '_Index → Row'), which is why this is P3, not a reason to remove _Index/_Dim from the tile bar.\n\nThe full records layout is not a stray-key hazard: it needs T r l c at least.\n\nProposed fix, per the PR's own note: order the tile list so the cursor opens on the first regular category (virtuals last, or at least after regulars), rather than filtering. Guard: a test that EnterTileSelect then cycle-axis-for-tile moves a regular category, not _Index.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:00:36Z","created_by":"agent-review-183","updated_at":"2026-09-23T15:48:57Z","started_at":"2026-09-23T14:55:55Z","closed_at":"2026-09-23T15:48:57Z","close_reason":"Fixed in PR #231, merged as 5932ad7 after adversarial review at 2383c37; the later changes were evidence-only merges of gh/main. Follow-ups: improvise-bqm0, improvise-q69m, improvise-52od.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-kkcy","title":"Browser p on a records coordinate or creating column is a silent Upstream; the TUI declines out loud since #184","description":"Found while fixing improvise-exdg. Since PR #184 the TUI's PasteCell declines on a records Region, +cat or + column with PASTE_ONTO_NO_VALUE ('Paste lands only on a measure column'). In the browser, resolve.rs's paste arm calls record_cell_at_cursor(state)?. That returns None for CellId::Coordinate and CellId::Create, so the resolver answers None, which is KeyOutcome::Upstream. Host::handle answers ClientFrame::Key with an empty vec, so the key does nothing and shows no status. #184's review (design call 2) called this 'matches the browser', but it matches only in not writing. The TUI tells the user; the browser is silent. improvise-exdg made the coordinate cell a named CellId::Coordinate, so the browser can now decline it deliberately with the TUI's text. The same applies to aggregates, where the TUI says PASTE_ONTO_AGGREGATE. Keep this separate from improvise-54g1 (Derived), which is the opposite divergence.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:58:25Z","created_by":"agent-viewhist","updated_at":"2026-09-23T04:58:25Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-kkcy","title":"Browser p on a records coordinate or creating column is a silent Upstream; the TUI declines out loud since #184","description":"Found while fixing improvise-exdg. Since PR #184 the TUI's PasteCell declines on a records Region, +cat or + column with PASTE_ONTO_NO_VALUE ('Paste lands only on a measure column'). In the browser, resolve.rs's paste arm calls record_cell_at_cursor(state)?. That returns None for CellId::Coordinate and CellId::Create, so the resolver answers None, which is KeyOutcome::Upstream. Host::handle answers ClientFrame::Key with an empty vec, so the key does nothing and shows no status. #184's review (design call 2) called this 'matches the browser', but it matches only in not writing. The TUI tells the user; the browser is silent. improvise-exdg made the coordinate cell a named CellId::Coordinate, so the browser can now decline it deliberately with the TUI's text. The same applies to aggregates, where the TUI says PASTE_ONTO_AGGREGATE. Keep this separate from improvise-54g1 (Derived), which is the opposite divergence.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:58:25Z","created_by":"agent-viewhist","updated_at":"2026-09-23T04:58:25Z","dependencies":[{"issue_id":"improvise-kkcy","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ssoe","title":"A builder calling KeyRow::looked_up directly draws a row help_bindings never records: a lying 'edit' caption on x passes the whole workspace","description":"Found in re-review of PR #180 at head 544744cf, the follow-up to improvise-v29m.\n\n`KeyRow` now closes hand-typed keys: a struct literal outside `key_row` is E0451, which I verified from the `improvise` crate. But `KeyRow::looked_up` is `pub(super)`, so any page builder in `help.rs` can call it directly instead of through its `keys` parameter. `help_bindings()` records only calls made through the parameter, so such a row is drawn but never recorded.\n\nTwo guards read `help_bindings()` and so cannot see it:\n- jhdf's `only_the_edit_key_is_captioned_as_editing_the_cell`;\n- 3t5x's `every_advertised_command_answers_in_its_own_mode`, through `advertised_everywhere`.\n\nInjected into `editing()`:\n\n KeyRow::looked_up(n, \"clear-cell\", None, \"Edit the current cell\").map(HelpLine::Keys),\n\nThat captions `x` (clear-cell) as editing the cell, which is the exact lie jhdf exists to catch. `cargo test --workspace --no-fail-fast`: **0 failures.**\n\nThe keys on such a row are still real, so wk18's claim that no key is hand-typed holds. What fails is `help_bindings`' own doc: \"the same builders, so a guard over it cannot drift from what is drawn\".\n\nFix: assert that the number of `HelpLine::Keys` rows in `pages()` equals the number of rows `help_bindings()` recorded that resolve. Or narrow `looked_up` so that only `keys()` can reach it, e.g. by moving `keys` into `key_row`. Then re-run the injection.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:51:47Z","created_by":"agent-review-180","updated_at":"2026-09-23T06:07:59Z","started_at":"2026-09-23T05:03:26Z","closed_at":"2026-09-23T06:07:59Z","close_reason":"Fixed in PR #198, merged after adversarial review (delta at f75b58a). Follow-up: improvise-5xbq.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dz8c","title":"The float_roundtrip check names its three crates by hand, so a new frame-parsing crate is not caught","description":"Found by the adversarial review of PR #190 (head 3c6001a). browser_round_trip.rs::every_build_that_parses_a_frame_enables_float_roundtrip iterates a literal list: improvise-wasm-client, improvise-worker-server (both with --target wasm32-unknown-unknown) and improvise-ws-server. It catches the feature being removed from one of those, verified by injection. It cannot see a fourth crate that starts parsing frames, for example a SharedWorker host or a second socket transport. The doc (context/code/protocol.md) says 'A new crate that parses frames belongs in its list', which is a convention with nothing to enforce it. Proposed derivation: take every workspace package from cargo metadata --no-deps with a NORMAL dependency on both improvise-protocol and serde_json, and require float_roundtrip on each. That set today is {improvise-command, improvise-wasm-client, improvise-ws-server, improvise-worker-server}. improvise-command is the one that fails, and it fails for the reason improvise-6yvg names (its JSON reads in ui/effect.rs). So this likely lands with or after 6yvg. The alternative is to enable the feature structurally in the crate that owns the frame types. That removes the list altogether but adds a normal serde_json dep to improvise-protocol, whose repo-map entry says it depends only on improvise-core and serde.","acceptance_criteria":"The set of crates the check covers is derived (cargo metadata, or structurally by where the feature is enabled), not listed by hand. A throwaway crate added to the workspace with normal deps on improvise-protocol and serde_json and no float_roundtrip makes the check fail, naming it.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-float2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:51:19Z","created_by":"agent-review-190","updated_at":"2026-09-23T06:08:00Z","started_at":"2026-09-23T05:06:49Z","closed_at":"2026-09-23T06:08:00Z","close_reason":"Fixed in PR #202, merged after adversarial review at feefe17; later changes were an evidence-only merge plus dropping --locked, as the reviewer recommended.","labels":["adversarial-review","pr-190"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h3ni","title":"pr-watch-test has no case for a head that moves during the UNKNOWN wait","description":"Found in adversarial review of PR #186 (head 128a2a0).\n\nThe script is correct here. The head-pin check runs on every poll, before any other logic, so a head that moves while the watch waits on UNKNOWN exits 2 (probe: UNKNOWN@aaaa -\u003e UNKNOWN@bbbb -\u003e CLEAN@bbbb exits 2, and with --expect aaaa it also exits 2). The suite does not pin it, though. The only head-move case, \"a head that moves mid-wait is not a pass\", moves while a check is IN_PROGRESS.\n\nRival that survives all 42 cases: re-pin the head while mergeable is UNKNOWN (`[ \"$mg\" = UNKNOWN ] \u0026\u0026 pinned=\"$head\"` ahead of the pin check). The probe exits 0 under it.\n\nFix: add a case where the head moves during the UNKNOWN wait (1.json UNKNOWN@aaaa, 2.json UNKNOWN@bbbb, last.json MERGEABLE/CLEAN@bbbb, want 2).\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:48:00Z","created_by":"agent-review-186","updated_at":"2026-09-23T05:05:45Z","started_at":"2026-09-23T04:51:57Z","closed_at":"2026-09-23T05:05:45Z","close_reason":"Fixed in PR #186, merged as 5bcc3ca after adversarial re-review at 04230a7. Follow-ups: improvise-9pfs, improvise-1qaa, improvise-qvpu.","labels":["adversarial-review-pr-186"],"comments":[{"id":"4d780d65-51d5-5063-bf61-b8cdb7336ebc","issue_id":"improvise-h3ni","author":"agent-tooling3","text":"Case added on PR #186 at 04230a7","created_at":"2026-09-23T04:59:23Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-gj5p","title":"R in a saved records layout says Pivot mode and changes nothing; the document opens in Normal over a records grid","description":"Found by improvise-jgzw (agent-registry). Reproduced in a scratch test at 1c184a5 on web/demo.improv.\n\nHand-assign _Index -\u003e Row and _Dim -\u003e Column in a real view (tile bar, or :set-axis). A real view persists every axis, so format_md writes both, and parse_md reopens the document in records layout. The doc on VIRTUAL_VIEW_NAMES says \"never-persisted _Index/_Dim on axes\", which is true only of _Records/_Drill.\n\nIn the reloaded App:\n\n1. The mode is Normal over a records grid (layout.is_records_mode() == true). This is the drifted state that bd9o's tests build by hand. App::new does not settle the mode off the layout.\n2. toggle-records-mode emits [CleanEmptyRecords, ViewBack, SetStatus(\"Pivot mode\")]. The back stack is empty, so ViewBack is a no-op and the layout stays records. The status line then says \"Pivot mode\", which is false, and R, the key the user knows, does nothing.\n\nThe only way out is to move _Index/_Dim with the tile bar or :set-axis. That is one of the reasons jgzw keeps them tileable (context/code/command.md, cmd/tile.rs).\n\nOptions, not decided here:\n- Make R's exit put _Index/_Dim to None on the active view when the back stack is empty.\n- Or have R say what it actually did, rather than claiming \"Pivot mode\".\n- Separately, settle the mode off the layout at load.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:45:13Z","created_by":"agent-registry","updated_at":"2026-09-23T04:45:13Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-48jd","title":"Paste inside a drill is declined, and the aggregate decline tells the user to drill in to paste","description":"From PR #184 review (0ed2e41). cell.rs: PASTE_ONTO_AGGREGATE reads 'Cannot paste onto an aggregate — drill in to paste into a record', but inside a drill paste_into_record declines with PASTE_INSIDE_DRILL ('Paste is not staged inside a drill — edit the cell instead'). So p on an aggregate sends the user to the one place p also refuses. Declining in the drill is a sound interim (it loses nothing: pending_edits holds strings and staging through Display would round, see improvise-umi4), but the gap is user-visible and unfiled. Close by (a) staging a typed CellValue in DrillState::pending_edits (the real fix context/code/command.md names), or at minimum (b) rewording PASTE_ONTO_AGGREGATE so it does not promise drill paste. Keep gotcha 19 in mind: the commit path's drill arm stages.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-eucp","title":"yy inside a drill yanks the snapshot value, not the staged edit the grid draws","description":"From PR #184 review (0ed2e41). YankCell now answers a synthetic key with GridLayout::records_value, which reads the drill's frozen records. But src/ui/grid.rs draws DrillState::pending_edits over those cells. Probe: drill into East, stage (0, Measure(Revenue)) = \"77\" in pending_edits, cursor on that cell, yy -\u003e yanked Number(1.0) (the snapshot) while the grid shows 77. Before #184 yank gave None there, so this is strictly better, but it is two derivations of what the cell holds (drawn vs yanked). Browser never drills (declined), so no lockstep impact. Fix: consult App's staged_drill_edit first, as display_value_in does — which runs into the pending_edits-are-strings problem of improvise-umi4 and the drill-paste ticket.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gj5p","title":"R in a saved records layout says Pivot mode and changes nothing; the document opens in Normal over a records grid","description":"Found by improvise-jgzw (agent-registry). Reproduced in a scratch test at 1c184a5 on web/demo.improv.\n\nHand-assign _Index -\u003e Row and _Dim -\u003e Column in a real view (tile bar, or :set-axis). A real view persists every axis, so format_md writes both, and parse_md reopens the document in records layout. The doc on VIRTUAL_VIEW_NAMES says \"never-persisted _Index/_Dim on axes\", which is true only of _Records/_Drill.\n\nIn the reloaded App:\n\n1. The mode is Normal over a records grid (layout.is_records_mode() == true). This is the drifted state that bd9o's tests build by hand. App::new does not settle the mode off the layout.\n2. toggle-records-mode emits [CleanEmptyRecords, ViewBack, SetStatus(\"Pivot mode\")]. The back stack is empty, so ViewBack is a no-op and the layout stays records. The status line then says \"Pivot mode\", which is false, and R, the key the user knows, does nothing.\n\nThe only way out is to move _Index/_Dim with the tile bar or :set-axis. That is one of the reasons jgzw keeps them tileable (context/code/command.md, cmd/tile.rs).\n\nOptions, not decided here:\n- Make R's exit put _Index/_Dim to None on the active view when the back stack is empty.\n- Or have R say what it actually did, rather than claiming \"Pivot mode\".\n- Separately, settle the mode off the layout at load.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:45:13Z","created_by":"agent-registry","updated_at":"2026-09-23T04:45:13Z","dependencies":[{"issue_id":"improvise-gj5p","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-48jd","title":"Paste inside a drill is declined, and the aggregate decline tells the user to drill in to paste","description":"From PR #184 review (0ed2e41). cell.rs: PASTE_ONTO_AGGREGATE reads 'Cannot paste onto an aggregate — drill in to paste into a record', but inside a drill paste_into_record declines with PASTE_INSIDE_DRILL ('Paste is not staged inside a drill — edit the cell instead'). So p on an aggregate sends the user to the one place p also refuses. Declining in the drill is a sound interim (it loses nothing: pending_edits holds strings and staging through Display would round, see improvise-umi4), but the gap is user-visible and unfiled. Close by (a) staging a typed CellValue in DrillState::pending_edits (the real fix context/code/command.md names), or at minimum (b) rewording PASTE_ONTO_AGGREGATE so it does not promise drill paste. Keep gotcha 19 in mind: the commit path's drill arm stages.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependencies":[{"issue_id":"improvise-48jd","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-eucp","title":"yy inside a drill yanks the snapshot value, not the staged edit the grid draws","description":"From PR #184 review (0ed2e41). YankCell now answers a synthetic key with GridLayout::records_value, which reads the drill's frozen records. But src/ui/grid.rs draws DrillState::pending_edits over those cells. Probe: drill into East, stage (0, Measure(Revenue)) = \"77\" in pending_edits, cursor on that cell, yy -\u003e yanked Number(1.0) (the snapshot) while the grid shows 77. Before #184 yank gave None there, so this is strictly better, but it is two derivations of what the cell holds (drawn vs yanked). Browser never drills (declined), so no lockstep impact. Fix: consult App's staged_drill_edit first, as display_value_in does — which runs into the pending_edits-are-strings problem of improvise-umi4 and the drill-paste ticket.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:39:07Z","created_by":"agent-review-184","updated_at":"2026-09-23T04:39:07Z","dependencies":[{"issue_id":"improvise-eucp","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:21Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-6yvg","title":"JSON import parses numbers 1 ULP off: the TUI build has no float_roundtrip","description":"Found while fixing improvise-6oy4, which enabled serde_json's float_roundtrip only on the three crates that parse FRAMES (ws-server, worker-server, wasm-client). The JSON import path, improvise-command ui/effect.rs (serde_json::from_slice::\u003cValue\u003e around lines 875 and 1008) and whatever improvise-io's import uses, parses numbers with the default best-effort parser. In the root improvise TUI binary's dependency graph no crate enables float_roundtrip, so importing a JSON file holding e.g. 0.24815714285714285 stores 0.24815714285714283. It was measured at roughly 1 in 7 ordinary decimals for the wire in 6oy4. The same parser here is unverified: this ticket has not been reproduced. The worker bundle happens to be exact, because worker-server enables the feature and unification reaches improvise-command in that build. So the same import gives different numbers in the TUI and the browser. Fix: float_roundtrip on improvise-io and improvise-command's serde_json deps (per crate, see context/code/protocol.md section 3), with a test importing that value and comparing f64 bits. Reproduce first.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-float2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:37:05Z","created_by":"agent-wirefloat","updated_at":"2026-09-23T06:08:00Z","started_at":"2026-09-23T05:06:42Z","closed_at":"2026-09-23T06:08:00Z","close_reason":"Fixed in PR #202, merged after adversarial review at feefe17; later changes were an evidence-only merge plus dropping --locked, as the reviewer recommended.","comments":[{"id":"fe35d006-2c89-55b7-bf0c-43f9a4ffdb34","issue_id":"improvise-6yvg","author":"agent-review-190","text":"Review of PR #190 (agent-review-190): one more parse path in the same family, not named above. improvise-io persistence/mod.rs::load deserializes a legacy-JSON workbook (a leading '{') with serde_json::from_str. The TUI build has no float_roundtrip anywhere (cargo tree -p improvise --invert serde_json -e normal,features shows none), so a legacy JSON file's numbers load through the lossy parser too. Unreproduced. It is the same fix and the same test shape.","created_at":"2026-09-23T04:51:29Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-54g1","title":"Paste onto a formula-owned (Derived) cell writes in the TUI and declines in the browser","description":"Found by the improvise-6p8q fixer (PR #184), out of that ticket's scope and not fixed. In the TUI, p on a cell whose value is owned by a formula (CellId::Derived per ADR 002) writes a stored cell under it; the formula's recompute then overrides the value, so the write is invisible but persists in ## Data. The browser declines, because Derived is not a settable CellId. Decide which is right. ADR 002 says Derived is the arm no write is constructible for, which argues the TUI should decline too, with a status that names the formula. Test through App::handle_key, save and reload, and assert no stored cell appears under the formula target. Lockstep: both ends agree.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:31:48Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:19:01Z","started_at":"2026-09-23T04:52:46Z","closed_at":"2026-09-23T15:19:01Z","close_reason":"Fixed in PR #197, merged as 705c92d after adversarial re-review at 087aa85. Later changes were evidence-only merges of gh/main. Follow-ups: improvise-ywj2, improvise-ciw8 (#217).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-s1lx","title":"Records-mode yy over a blank cell yanks Text(\"\") on both ends; p then writes an empty text cell","description":"Found fixing improvise-6p8q. GridLayout::records_value answers Text(\"\") for a creating column (+cat, +) and for a measure the record lacks. Since improvise-dsxq the browser yanks CellDisplay::value, filled from records_value, and since 6p8q the TUI yanks records_value too, so both ends agree: yy over a blank records cell yanks Some(Text(\"\")), where an empty pivot cell yanks None. p then writes SetCell(record, Text(\"\")), an invisible empty-text cell that persists into ## Data. Fix at the producer (records_value or the projection's value for a blank), not in one resolver, or the ends diverge again: tests/lockstep.rs::yy_then_p_in_records_mode_writes_the_record_the_host_writes asserts the two registers agree on the + column and currently expects Text(\"\"); update it with the fix.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:06Z","created_by":"agent-6p8q","updated_at":"2026-09-23T04:28:06Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-s1lx","title":"Records-mode yy over a blank cell yanks Text(\"\") on both ends; p then writes an empty text cell","description":"Found fixing improvise-6p8q. GridLayout::records_value answers Text(\"\") for a creating column (+cat, +) and for a measure the record lacks. Since improvise-dsxq the browser yanks CellDisplay::value, filled from records_value, and since 6p8q the TUI yanks records_value too, so both ends agree: yy over a blank records cell yanks Some(Text(\"\")), where an empty pivot cell yanks None. p then writes SetCell(record, Text(\"\")), an invisible empty-text cell that persists into ## Data. Fix at the producer (records_value or the projection's value for a blank), not in one resolver, or the ends diverge again: tests/lockstep.rs::yy_then_p_in_records_mode_writes_the_record_the_host_writes asserts the two registers agree on the + column and currently expects Text(\"\"); update it with the fix.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:06Z","created_by":"agent-6p8q","updated_at":"2026-09-23T04:28:06Z","dependencies":[{"issue_id":"improvise-s1lx","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:24Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i3o5","title":"Refusals still written through set_status in panel.rs, keymap dispatch and draw.rs exit 0","description":"Left open by the udt2 change (App::refuse / effect::refuse, context/code/cli.md section 10). A refusal now sets a non-zero headless exit only where its writer calls App::refuse or emits effect::Refuse; a refusal still written through effect::set_status exits 0 as before.\n\nConverted: every refusal in ui/effect.rs, and in cmd/{commit,fit,grid,mode,search,text_buffer}.rs.\n\nNOT converted, because the files were outside the udt2 agent's lane:\n- cmd/panel.rs: \"Move cursor to a category header to change axis\", \"No category selected. Press n to add a category first.\", \"No category to delete\".\n- command/keymap.rs::dispatch: a bound command whose args fail to parse (set_status(e)). TUI-only, never reached headless.\n- src/draw.rs: \"no array found in that JSON\", \"category already exists\" (direct status_msg writes).\n- cmd/mode.rs Quit's \"Unsaved changes\" is deliberately left a report; see the sibling ticket about headless q.\n\nEach is a one-token change (set_status -\u003e refuse, or status_msg = -\u003e app.refuse). The guard to extend is tests/headless_cli.rs::a_refusal_that_only_writes_a_status_exits_non_zero.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-refuse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:25:35Z","created_by":"agent-cli","updated_at":"2026-09-23T15:47:40Z","started_at":"2026-09-23T15:31:39Z","closed_at":"2026-09-23T15:47:40Z","close_reason":"Partly fixed in PR #236 (4 declines now refuse), merged as b4779ed after adversarial review at 7b04556. The rest (panel.rs x3 and the keymap dispatch arm) goes to the follow-up per the orchestrator's ruling; see the bead notes.","labels":["area:cli"],"comments":[{"id":"13862144-6c73-5b9c-87d9-2d7535d1411a","issue_id":"improvise-i3o5","author":"agent-review-213","text":"From the adversarial review of PR #213 at 3929196: two more refusals are written with set_status and so exit 0. This ticket does not list either.\n- cmd/mode.rs EditOrDrill, the collapsed-group arm (\"Collapsed group — press z to expand it...\"). Its two sibling arms in the same Cmd, \"No cell at cursor\" and \"computed by a formula\", both refuse. context/code/cli.md section 10 says every refusal in cmd/mode.rs goes through refuse, and this arm does not.\n- cmd/cell.rs paste_into / paste_into_record: PASTE_ONTO_AGGREGATE, PASTE_INSIDE_DRILL and PASTE_ONTO_NO_VALUE. The paste did not happen, so by section 10's rule they are refusals.\nBoth predate #185 (4f225e88f and 9d04b981/8df94311, dated 2026-09-22), so #185's sweep, and 0v8l's after it, never enumerated them. The sweeps grep for refuse( and cannot see a refusal written as set_status.\n","created_at":"2026-09-23T14:51:53Z"},{"id":"879f3777-a8fc-590f-a669-d34ea3e7db09","issue_id":"improvise-i3o5","author":"agent-refuse-fu","text":"agent-refuse-fu 2026-09-23, PR #236: converted the four declines the #213 review named. Those are EditOrDrill's collapsed-group arm and paste_into/paste_into_record's aggregate, coordinate-column and inside-a-drill declines. Each is pinned in the key-path refusal table with a succeeding twin, and a per-site revert fails 4 of 4.\n\nSTILL written with set_status on a decline path, found by reading every production set_status and status_msg write at bb5dfe5:\n- cmd/panel.rs:708 \"Move cursor to a category header to change axis\"\n- cmd/panel.rs:726 \"No category selected. Press n to add a category first.\"\n- cmd/panel.rs:818 \"No category to delete\"\n These three are reachable headless by their : names, so they exit 0.\n- command/keymap.rs:64, dispatch's set_status(e). Every interactive-constructor Err lands here, including the key-path forms of the four declines PR #236 converts (p/x/i onto an aggregate, a fold, a coordinate column or inside a drill). Only the TUI reaches it.\n\nNOT refusals, or stale:\n- mode.rs:313 Quit \"Unsaved changes\" is deliberately a report (improvise-ep5o).\n- The src/draw.rs strings listed in this bead's description now occur only in draw.rs's test module, so that item is stale.\n- improvise-protocol resolve.rs Command::SetStatus declines are browser-local; no frame is composed for them.\n","created_at":"2026-09-23T15:39:16Z"},{"id":"8da844ea-5c18-5df9-8cb7-3b7e9ff2ad7a","issue_id":"improvise-i3o5","author":"agent-review-236","text":"agent-review-236, review of PR #236 at 7b045567: the description's src/draw.rs bullet is not a production site. Both writes ('no array found in that JSON', 'category already exists', src/draw.rs:617/656) sit inside #[cfg(test)] mod bottom_bar_tests. The production set_status declines still unconverted are exactly: cmd/panel.rs:708, :726, :818 and command/keymap.rs::dispatch's arg-error arm (plus Quit, deliberate, ep5o). Every other production set_status/status_msg write was read and is a success report.","created_at":"2026-09-23T15:46:26Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} -{"_type":"issue","id":"improvise-ep5o","title":"Headless q on a dirty document says Unsaved changes and then saves anyway","description":"Found while closing improvise-udt2. `improvise cmd 'add-category X' 'q' -f doc.improv` prints \"Unsaved changes. Use :q! to force quit or :wq to save+quit.\" to stderr, exits 0, and the CLI then writes the document anyway, because run_headless_commands saves whenever a command changed it (context/code/cli.md section 4).\n\nSo this refusal is not merely unclassified: its text is false on this surface. Quit's status was deliberately left a report (not effect::refuse) in the udt2 change, because making it exit 1 would fail a script over a message that does not describe what happened. Decide what `q` means headless (probably nothing, since the process ends anyway) before classifying it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:25:32Z","created_by":"agent-cli","updated_at":"2026-09-23T04:25:32Z","labels":["area:cli"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ep5o","title":"Headless q on a dirty document says Unsaved changes and then saves anyway","description":"Found while closing improvise-udt2. `improvise cmd 'add-category X' 'q' -f doc.improv` prints \"Unsaved changes. Use :q! to force quit or :wq to save+quit.\" to stderr, exits 0, and the CLI then writes the document anyway, because run_headless_commands saves whenever a command changed it (context/code/cli.md section 4).\n\nSo this refusal is not merely unclassified: its text is false on this surface. Quit's status was deliberately left a report (not effect::refuse) in the udt2 change, because making it exit 1 would fail a script over a message that does not describe what happened. Decide what `q` means headless (probably nothing, since the process ends anyway) before classifying it.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:25:32Z","created_by":"agent-cli","updated_at":"2026-09-23T04:25:32Z","labels":["area:cli"],"dependencies":[{"issue_id":"improvise-ep5o","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:15Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4w09","title":"A NaN cell never quiesces: CellDisplay's PartialEq makes the grid diff resend it every projection and breaks folded(seed)==cache","description":"Found by adversarial review of PR #178 (improvise-dsxq), head 5d1bf752.\n\n#178 put `value: Option\u003cCellValue\u003e` on CellDisplay and dropped `Eq` from CellDisplay/GridCache/GridUpdate. A cell holding Number(NaN) is legal (.improv carries `nan`, improvise-55v2), and it now compares unequal to itself. Measured with a one-cell document holding NaN:\n\n- grid_updates(\u0026g, \u0026g) is NOT empty. It returns Cells([((0,0), \u003cthe NaN cell\u003e)]).\n- GridCache::folded(\u0026g.seed()) == g is FALSE, so the compaction-equivalence property gotcha 32 says earns the seed does not hold for this document.\n- Sessions::project_all with Dirty::EVERYTHING three times in a row resends the NaN cell every time. The grid for this document never quiesces.\n\nBefore #178 the diff compared `text` (\"NaN\" == \"NaN\"), so this is new. The cost today is one redundant cell per grid-dirty projection per NaN cell, hence P3. The risk is any future code or test that relies on \"a projection of an unchanged grid is empty\" or on the seed round-trip, which is false exactly for documents that carry NaN.\n\nPossible answers: compare numbers by bits (to_bits / total_cmp) in the diff, or give CellDisplay a hand-written PartialEq that does. Do not re-add Eq by lying about f64.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-wirefloat","owner":"el-github@elangley.org","created_at":"2026-09-23T04:22:10Z","created_by":"agent-review-178","updated_at":"2026-09-23T05:03:58Z","started_at":"2026-09-23T04:24:24Z","closed_at":"2026-09-23T05:03:58Z","close_reason":"Fixed in PR #190, merged as 1158c23 (reviewed at 3c6001a; the only change after that was an evidence-only merge of gh/main at 44a183a).","labels":["adversarial-review","pr-178"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yk2s","title":"H (hide-selected-row-item) on a collapsed group's row hides a nonexistent item named like the group: silent no-op, ghost hidden entry persisted","description":"Found by the ChatGPT Codex review of PR #176 (improvise-6qet) at head b014a97. Independently reproduced in the adversarial review.\n\n`HideSelectedRowItem` (`H`) reads `tuple[0]` of the data item under the cursor. On a collapsed group's row that is the group name, so it emits `HideItem { category: Month, item: \"Q1\" }`. `View::hide_item` accepts unknown names. The result is a ghost `Month/Q1` entry in `hidden_items` that gets persisted. The status line stays empty, and the rows are unchanged (`[\"Q1\", \"Apr\"]` before and after), so the key silently does nothing.\n\nFix: refuse on a fold, as `EditOrDrill` does (ask `layout.folds_a_group`), or define what hiding a group means. A regression test should press `H` on a fold that is not at row 0.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:21:04Z","created_by":"agent-review-176","updated_at":"2026-09-23T15:19:01Z","started_at":"2026-09-23T04:38:24Z","closed_at":"2026-09-23T15:19:01Z","close_reason":"Fixed in PR #197, merged as 705c92d after adversarial re-review at 087aa85. Later changes were evidence-only merges of gh/main. Follow-ups: improvise-ywj2, improvise-ciw8 (#217).","labels":["adversarial-review","pr-176"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-59q8","title":"source_scan::strip_test_items hides all following production code after a multi-line test-only item; cfg(all(test,not(..))) and #![cfg(test)] read as production","description":"Found by the adversarial review of PR #172 (head 0b3fb05). None of these shapes occurs in the tree today (surveyed every `#[cfg(...test...)]` in crates/*/src and src/), so this is latent. But both vocabulary guards now depend on this scanner, and two of the misreads fail silently.\n\n## Steps to Reproduce\n\nFeed each text to `source_scan::production_text` (run as a scratch test and then reverted):\n\n1. **A test-only item that ends in `];`, `);` or `};`, or a `#[cfg(test)]` statement spanning lines.**\n impl X {\n #[cfg(test)]\n const NAMES: \u0026[\u0026str] = \u0026[\n \"a\",\n ];\n fn prod() { let c = Command::Hidden1(1); }\n }\n fn after() { Command::Visible(2) }\n Output: `impl X {` and nothing else. The first line does not end in `;`, so `strip_test_items` looks for ` }` at the attribute's indent, never finds it, and drops everything to EOF. The `#[cfg(test)] let x = foo(\\n 1,\\n);` statement form does the same.\n2. **A one-line `#[cfg(test)] use foo::bar;`.** It drops the whole rest of the file, because the attribute's own line is also the item line.\n3. **`#[cfg(all(test, not(target_arch = \"wasm32\")))]`** is kept as production, because `is_test_cfg` rejects any line containing `not(`.\n4. **Inner `#![cfg(test)]`** at the top of a module file is kept as production, because only `#[cfg(` is recognised.\n5. (Minor) C raw strings (`cr#\"…\"#`): the `r` has a word character before it, so the string is lexed as ordinary quotes and its contents leak.\n\nDirections: 1 and 2 **hide** production code. In `vocabulary_is_complete` a hidden effect is silently absent, so a new unlisted effect after such an item passes. 3 and 4 **count** test code, so in `vocabulary_is_composed` a variant composed only by such a test reads as composed.\n\nThe lexer half held against nested block comments, `r##\"…\"#…\"##`, byte strings containing `//` and `\\\"`, `'\"'`, `b'\"'`, `'\\u{27}'`, loop labels, and `'a` next to `'a'`.\n\n## Acceptance Criteria\n\n- Each shape above is a case in `source_scan`'s tests, and each fails against the current scanner.\n- `strip_test_items` either brace- or bracket-balances the item (on blanked text), or refuses (panics) on an item shape it cannot bound. It must not scan to EOF silently.\n- `is_test_cfg` evaluates the cfg predicate rather than using a substring test for `not(`, and handles `#![cfg(test)]`.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:16:04Z","created_by":"agent-review-172","updated_at":"2026-09-23T05:15:40Z","started_at":"2026-09-23T04:49:32Z","closed_at":"2026-09-23T05:15:40Z","close_reason":"Fixed in PR #195, merged after adversarial review at 43ba5e6. Follow-ups: improvise-yqnp, improvise-z4up, improvise-e958.","labels":["adversarial-review","pr-172","protocol","testing"],"comments":[{"id":"2d9f9c5e-3275-5cfa-b390-e5fd5cab66a4","issue_id":"improvise-59q8","author":"agent-review-172","text":"PR #172 re-review at 119f84f: another instance. A #[cfg(test)] attribute INSIDE simple_effect!( ... ) makes strip_test_items drop the rest of effect.rs, because the item line ends in neither ';' nor a closing brace at that indent. An unlisted production effect placed after it escaped every_effect_is_on_the_wire_or_has_a_verdict. It surfaced only because the_verdict_list_names_only_real_effects failed on 'no effect is named StartDrill', which is luck: it depends on a verdict-listed effect lying after the cut. The shape cannot compile in a non-test build, because the macro gates only the struct and not the impl, so this is not reachable from shipped code.","created_at":"2026-09-23T04:26:50Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-1o0y","title":"agent-rules.md hard-codes 'Posted by Claude' in a file SKILL.md says is agent-neutral for Codex","description":"Found reviewing PR #175 at b6d2c47.\n\nPR #175 makes one file serve both Claude and Codex (`.claude/skills/wave` symlinked to `.agents/skills/wave`), and its SKILL.md \"Shepherding\" says so: the attribution \"wording is agent-neutral because this one file serves Claude and Codex\". (That drift, \"a Claude agent\" in one copy and \"a Codex agent\" in the other, is part of what the PR fixes.)\n\nBut `agent-rules.md` \"GitHub\" hard-codes the line: `_Posted by Claude (\u003cmodel\u003e), an agent in Edward Langley's session._`. A Codex agent briefed from it is told to post as Claude. So the PR fixes the disagreement in SKILL.md and adds it again between SKILL.md and agent-rules.md.\n\nA tension to settle, not assume: AGENTS.md \"Shipping a wave\" and kb.pl `doctrine(read_the_reviews_before_merging_and_authorship_will_not_identify_them)` use \"ours OPEN with Posted by Claude\" as the tell that tells our posts apart from a ChatGPT Codex *review*. An agent-neutral line has to keep that tell working, e.g. the tell becomes \"Posted by\", not \"Posted by Claude\".\n\n## Acceptance Criteria\n- agent-rules.md's attribution template names the posting model without assuming Claude, and AGENTS.md's \"ours open with\" tell still separates our posts from a Codex review.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:12:37Z","created_by":"agent-review-175","updated_at":"2026-09-23T04:22:21Z","closed_at":"2026-09-23T04:22:21Z","close_reason":"PR #175 MERGED (confirmed at 9f3f530), adversarially reviewed then re-reviewed after fixes (merge). The agent standing rules now live in the repo at .agents/skills/wave/agent-rules.md, replacing the non-durable /tmp/salvage/standing-rules.md. .claude/skills/wave is a symlink to .agents/skills/wave, so the two drifted copies are one. SKILL.md is the brief checklist's only home, and AGENTS.md points to it. The review confirmed every rule from the /tmp file landed, and the fix restored the two items the move had dropped: hand-back identifiers (branch, head SHA, PR number) and the base-commit check.","labels":["adversarial-review","docs","pr-175"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ximf","title":"fmt-workspaces.sh help says exit 2 names NO file, but the new orphan arm names a Cargo.toml","description":"Raised by ChatGPT Codex on PR #170 (improvise-90ea), head 808cbb7d. Confirmed by the adversarial review of the same head.\n\nscripts/fmt-workspaces.sh usage() now lists \"a [package] that no root has as a member\" as an exit-2 cause, and the same paragraph still says \"NO verdict was produced and NO file is named.\" The new orphan arm prints each unreached Cargo.toml, and the fixture case \"and the unreached package is named\" requires that path in the output. So the help text gives exit 2 two incompatible meanings. The exit-code contract is the reason this script exists (improvise-b4ks).\n\nFix: narrow the sentence, e.g. \"no SOURCE file is named as a formatting failure; a preflight cause may name a manifest\". Pin the wording with a fixture case if the help text is meant to be a contract. Doc-only, no behaviour change.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:07:41Z","created_by":"agent-review-169","updated_at":"2026-09-23T04:19:32Z","closed_at":"2026-09-23T04:19:32Z","close_reason":"PR #170 MERGED (confirmed), adversarially reviewed (merge after the ximf one-line fix, which was made); CI green at d11d2ac per pr-watch --expect. scripts/fmt-workspaces.sh: the fixture now misformats EVERY package it finds (discovered independently of the script) and requires each to be named exactly once, and the gate itself refuses (exit 2) if any [package] in the tree belongs to no workspace root. That structural check is what stops a fifth fixture gap; the reviewer added a fourth fixture package and it got its own case automatically. usage() now says exit 2 means NO VERDICT was produced.","labels":["adversarial-review","pr-170","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ulhd","title":"ktlk's editor seed is unpinned by position: every absent-buffer test has the cursor at (0,0) with zero offsets","description":"Found by adversarial review of PR #169 (improvise-ktlk), head bdf9bff4.\n\nThe ktlk seed (ClientState::settle_editor -\u003e resolve::edit_seed) picks the cell under the cursor through cell_at_cursor, which subtracts the scroll offsets. The code is correct. But no test pins WHICH cell the seed comes from. Every test that reaches the absent-buffer path (resolve::tests::an_editor_entered_without_its_buffer_commits_and_extends_the_cell, render::tests::an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank, render::tests::an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell) has the cursor at (0,0) with zero offsets. That is the standing \"cursor at index 0\" vacuity.\n\nINJECTED, run: edit_seed rewritten as\n let (r, c) = state.view.cursor().selected;\n state.grid.cell(r, c)?.edit_text.clone()\n(ignores the offsets). Full workspace: 1748 passed, 0 failed. Under a scrolled cursor that rival seeds from a different cell's edit_text, and Enter then commits that other value into this cell (silent data corruption, the same class as ktlk).\n\nDiscriminating fixture, verified: two record cells at viewport (0,0)=\"111\" and (1,0)=\"222\", SetSelected{1,0}, SetRowOffset(1), fold ChangeMode(editing) with no SetBuffer, assert read_buffer(\"edit\") == \"111\". Passes on bdf9bff4; fails under the rival with \"222\".\n\nFix: add that test (resolve.rs tests, next to the ktlk test) plus an evidence entry. Test-only.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:07:30Z","created_by":"agent-review-169","updated_at":"2026-09-23T04:07:30Z","labels":["adversarial-review","browser","pr-169","test-gap"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ulhd","title":"ktlk's editor seed is unpinned by position: every absent-buffer test has the cursor at (0,0) with zero offsets","description":"Found by adversarial review of PR #169 (improvise-ktlk), head bdf9bff4.\n\nThe ktlk seed (ClientState::settle_editor -\u003e resolve::edit_seed) picks the cell under the cursor through cell_at_cursor, which subtracts the scroll offsets. The code is correct. But no test pins WHICH cell the seed comes from. Every test that reaches the absent-buffer path (resolve::tests::an_editor_entered_without_its_buffer_commits_and_extends_the_cell, render::tests::an_editing_mode_with_no_edit_buffer_shows_the_cell_rather_than_a_blank, render::tests::an_edit_buffer_the_user_has_emptied_is_not_refilled_from_the_cell) has the cursor at (0,0) with zero offsets. That is the standing \"cursor at index 0\" vacuity.\n\nINJECTED, run: edit_seed rewritten as\n let (r, c) = state.view.cursor().selected;\n state.grid.cell(r, c)?.edit_text.clone()\n(ignores the offsets). Full workspace: 1748 passed, 0 failed. Under a scrolled cursor that rival seeds from a different cell's edit_text, and Enter then commits that other value into this cell (silent data corruption, the same class as ktlk).\n\nDiscriminating fixture, verified: two record cells at viewport (0,0)=\"111\" and (1,0)=\"222\", SetSelected{1,0}, SetRowOffset(1), fold ChangeMode(editing) with no SetBuffer, assert read_buffer(\"edit\") == \"111\". Passes on bdf9bff4; fails under the rival with \"222\".\n\nFix: add that test (resolve.rs tests, next to the ktlk test) plus an evidence entry. Test-only.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:07:30Z","created_by":"agent-review-169","updated_at":"2026-09-23T04:07:30Z","labels":["adversarial-review","browser","pr-169","test-gap"],"dependencies":[{"issue_id":"improvise-ulhd","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5fzx","title":"Three skills are byte-identical hand-kept copies under .claude/skills and .agents/skills","description":"kb, pr-watch and code-review each exist as two byte-identical directories, .claude/skills/\u003cname\u003e and .agents/skills/\u003cname\u003e (verified with diff -rq on 2026-09-23). That is the drift that improvise-1f8e found in the wave skill: its two copies had diverged (neither had PR #167's commit-frequently rule, and one said 'Claude agent' where the other said 'Codex agent'). PR #175 fixed wave the way beads-merge is already done: .claude/skills/wave became a symlink to .agents/skills/wave. Apply the same fix to the other three, after checking that the Skill loader follows the symlink (#175 left that unverified). A live hazard: improvise-ih3k/yea8/za97 are being fixed right now by an agent told to edit BOTH kb copies and keep them in sync by hand, which is exactly how they drift. Do this after that PR lands, to avoid a conflict.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-tooling3","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:28Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:51:03Z","started_at":"2026-09-23T04:30:39Z","closed_at":"2026-09-23T04:51:03Z","close_reason":"PR #189 MERGED (confirmed at 8641f9a), adversarially reviewed (merge, no findings). .claude/skills/{kb,pr-watch,code-review} are symlinks to .agents/skills/\u003cname\u003e (mode 120000), so every repo skill has one copy. The code-review skill's gate list now points at agent-rules.md 'Gates' rather than restating it, which removes the stale 'cargo fmt --all --check'.","comments":[{"id":"40346b4f-f104-53b7-a304-d1bc4fac5ad7","issue_id":"improvise-5fzx","author":"agent-tooling3","text":"PR #189 at 8641f9a (branch fix/5fzx-skill-symlinks)","created_at":"2026-09-23T04:41:57Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-b5zh","title":"prune_axis_items: close_pending_run's 'header whose run saw no item is collapsed' arm is unreachable since 6qet","description":"Since improvise-6qet a collapsed group emits one DataItem (its fold), so a header followed by no item at all can no longer be produced by expand_category. The doc comment on close_pending_run ('A header still pending saw either no item at all — it is collapsed, and survives') is now a stale claim in code, and the arm it describes is unreachable. Also: under prune_empty an EMPTY collapsed group now prunes its fold row and the header with it — a deliberate narrowing of improvise-lhi4 (context/code/core.md §14). Decide whether to delete the arm and simplify, and correct the comment and context/test-evidence/core.md's lhi4 entry, which still explains the arm.","status":"closed","priority":3,"issue_type":"task","assignee":"agent-collapse-fu","owner":"el-github@elangley.org","created_at":"2026-09-23T04:06:18Z","created_by":"agent-collapse3","updated_at":"2026-09-23T06:35:14Z","started_at":"2026-09-23T04:38:25Z","closed_at":"2026-09-23T06:35:14Z","close_reason":"Fixed in PR #200, merged as 7767cee after adversarial review at 561cff4. Follow-up: improvise-e265.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j4i9","title":"TUI help overlay silently clips every page below ~50 rows: no scroll, no 'more' marker","description":"Pre-existing; found reviewing PR #168 (improvise-s8h7) at 334e218. src/ui/help.rs HelpWidget::render draws page_content rows until content_height (= height - 2*margin - 2 border - 4) and then breaks: no scrolling, no indicator that rows were dropped. Measured with a throwaway probe that renders each page and looks for its last row, width 100: minimum terminal height to see the last row is Welcome 45, Navigation 47 (46 on gh/main — #168 adds a row), Editing 44 (45 on gh/main), Panels \u0026 Views 50, Commands 48. So at 24 or 40 rows every page is truncated, silently: on Navigation at 24 rows the whole Search and View history sections are gone. #168 did not create this and does not raise the overall need (still 50, set by Panels \u0026 Views); it moves Navigation's own threshold by one. Fix direction is not obvious — scroll within a page (j/k are free in Help mode?) or split pages — so filed rather than prescribed. A test would render at a small height and assert either the last row or a truncation marker is drawn.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:22Z","created_by":"agent-review-s8h7","updated_at":"2026-09-23T04:03:22Z","labels":["adversarial-review","help","pr-168","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j4i9","title":"TUI help overlay silently clips every page below ~50 rows: no scroll, no 'more' marker","description":"Pre-existing; found reviewing PR #168 (improvise-s8h7) at 334e218. src/ui/help.rs HelpWidget::render draws page_content rows until content_height (= height - 2*margin - 2 border - 4) and then breaks: no scrolling, no indicator that rows were dropped. Measured with a throwaway probe that renders each page and looks for its last row, width 100: minimum terminal height to see the last row is Welcome 45, Navigation 47 (46 on gh/main — #168 adds a row), Editing 44 (45 on gh/main), Panels \u0026 Views 50, Commands 48. So at 24 or 40 rows every page is truncated, silently: on Navigation at 24 rows the whole Search and View history sections are gone. #168 did not create this and does not raise the overall need (still 50, set by Panels \u0026 Views); it moves Navigation's own threshold by one. Fix direction is not obvious — scroll within a page (j/k are free in Help mode?) or split pages — so filed rather than prescribed. A test would render at a small height and assert either the last row or a truncation marker is drawn.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:22Z","created_by":"agent-review-s8h7","updated_at":"2026-09-23T04:03:22Z","labels":["adversarial-review","help","pr-168","tui"],"dependencies":[{"issue_id":"improvise-j4i9","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1f8e","title":"wave skill's brief checklist not updated with PR #167's commit-frequently and push-own-branch rules","description":"Found reviewing PR #167 at ef16723.\n\nPR #167 renumbers AGENTS.md's \"Every wave agent's brief must include\" list: new item 4 \"Commit frequently, so the work is safe in git\", and item 5 changes \"Do not push, do not merge to main\" to \"Do not merge to main ... Pushing the agent's own branch (git push gh HEAD:refs/heads/\u003cbranch\u003e) is encouraged\".\n\nThe `wave` skill carries its own copy of the same checklist (\"What every brief must carry\", `.claude/skills/wave/SKILL.md` and the identical `.agents/skills/wave/SKILL.md`) and was not touched. Its item 6 still reads \"Do not push to `main`, do not merge.\" and it has no commit-frequently item. The skill is what an orchestrator loads to write a brief, so briefs built from it will omit the rule the PR exists to add. `git grep -n -iE 'do not push|commit' ef16723 -- .claude/skills/wave .agents/skills/wave` shows it.\n\nThis is the stale-sibling shape: one statement of a rule updated, its copy not.\n\n## Acceptance Criteria\n- both wave skill copies carry the commit-frequently item and the push-own-branch wording, or point at AGENTS.md instead of restating the list.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T03:58:33Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:22:18Z","closed_at":"2026-09-23T04:22:18Z","close_reason":"PR #175 MERGED (confirmed at 9f3f530), adversarially reviewed then re-reviewed after fixes (merge). The agent standing rules now live in the repo at .agents/skills/wave/agent-rules.md, replacing the non-durable /tmp/salvage/standing-rules.md. .claude/skills/wave is a symlink to .agents/skills/wave, so the two drifted copies are one. SKILL.md is the brief checklist's only home, and AGENTS.md points to it. The review confirmed every rule from the /tmp file landed, and the fix restored the two items the move had dropped: hand-back identifiers (branch, head SHA, PR number) and the base-commit check.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-za97","title":"kb completeness guard docs: refusal suggests committing the loss, kb skill not updated, residue paragraph wrong","description":"Found reviewing PR #166 (improvise-usus) at a55b3c7. Three documentation defects around the new completeness guard; none is a code bug, all are where the next reader looks.\n\n1. **The refusal offers the committed-loss hole as a way out.** On an unexplained loss `scripts/kb` prints `kb: restore it from HEAD and re-apply your deltas, or commit the removal.` The second option, taken on a loss nobody intended, commits the loss, and `context/code/kb-tool.md` itself says \"a bad commit of kb.pl becomes the new floor\". An agent following the message literally makes the incident permanent and invisible. The message should say commit only a removal you intended and verified, and point at `kb rm` as the way to record one.\n\n2. **The `kb` skill (`.claude/skills/kb/SKILL.md`), which AGENTS.md says to read before the first write, was not updated.** It still says \"Every write verifies the result reads back as intended and runs `kb_consistent`\", with no mention of the HEAD comparison, the ledger, exit code 3, what the new refusal means, how to recover, or that a committed loss is the new floor. The argument is only in `context/code/kb-tool.md`.\n\n3. **`kb-tool.md` states a residue hazard that does not exist, and understates the residue.** It says a SIGKILL'd `kb.pl.cand.*` \"shows as untracked in prolog-sessions/, where a git add -A would pick it up\". Ran it: SIGKILL via `KB_TEST_KILL` on a copy inside `prolog-sessions/` left `rvcopy.pl.cand.XXXX`, `.cand.XXXX.base`, `.cand.XXXX.ledger`, `.cand.XXXX.removed` and `.lock`; `git status --untracked-files=all` and `git add -A --dry-run` both show nothing, because `.gitignore:40` ignores `prolog-sessions/*`. So the paragraph warns about a hazard that the tree already prevents and names one file where four are left.\n\n## Acceptance Criteria\n- the refusal message no longer offers committing an unexplained loss;\n- the kb skill describes the completeness refusal, the ledger and the recovery;\n- the residue paragraph matches what `git status` shows.\n","status":"closed","priority":3,"issue_type":"task","assignee":"agent-kbguard2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:56:41Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:29:57Z","started_at":"2026-09-23T04:01:23Z","closed_at":"2026-09-23T04:29:57Z","close_reason":"PR #179 MERGED (confirmed at 9472989), adversarially reviewed (merge); CI 14/0 including the kb job on Linux with GNU stat. ih3k: the completeness guard counts clauses PER KEY, so losing one clause of a pair that shares a key is refused. yea8: the kb rm ledger carries a stamp (sha256 + inode + ns-mtime) of the file its write published, and any rewrite outside the tool voids it. A content hash alone would not work: kb.pl is canonical, so grep -v of a clause is byte-identical to kb rm output. In the reviewer's repro the in-place rewrite even keeps the inode, so the mtime is the part doing the work. za97: the refusal no longer offers committing the loss, and the kb skill documents the guard. Follow-up: improvise-4zei (P4, case 25 cannot see a content-only stamp; case 24 still guards it).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yea8","title":"A stale kb rm ledger entry excuses a later non-rm loss of that clause (restore or branch switch does not prune it)","description":"Found reviewing PR #166 (improvise-usus) at a55b3c7.\n\nThe `kb rm` ledger (`git rev-parse --git-path kb-removed.pl`, per worktree, not per branch) is pruned only when a later `scripts/kb` WRITE runs. `kb-tool.md` says an entry \"drops once the removal is committed, and once the key is re-added\", but a commit, a `git checkout -- prolog-sessions/kb.pl` and a branch switch are not writes. Until the next write, the entry excuses the loss of that exact clause by any path, which is the loss the guard exists to catch.\n\nSteps to reproduce (fixture copies, KB_FILE/KB_BASELINE, so the ledger is `$KB.removed`; the pruning logic is identical to the git-path one):\n1. copy = HEAD kb.pl. `printf \"location(cli_args,'src/main.rs',_).\\n\" \u003e rmX.pl; kb rm rmX.pl` -\u003e ledger holds `location(cli_args,'src/main.rs',A)`.\n2. Undo it the way the refusal message recommends: `cp HEAD-kb copy` (a restore; not a write, so no prune).\n3. Lose that clause by another path: `grep -v '^location(cli_args,' HEAD-kb \u003e copy`.\n4. `kb add \u003cnew doctrine\u003e` -\u003e exit 0, `complete -- baseline 809, result 809, 1 removal(s) recorded`. The loss was excused.\n- Control: the same step-3 copy with no ledger is refused, naming `location(cli_args,'src/main.rs',clap)`.\n\nThe branch-switch variant: `kb rm X` on branch A (committed or abandoned), switch to B whose HEAD has X; nothing prunes until a write, so a merge-resolution that drops X on B is excused by A's entry.\n\nThe window is narrow (one specific clause, until the next write). Pruning against the kb as read at the next write does NOT close it: by then the loss has already happened and the read kb lacks X too. One option is to record the HEAD commit and the kb stamp with each entry and discard entries whose recorded kb state is not the one being written onto (the rm's own output file is the only kb its entries may excuse); another is to key the ledger by HEAD commit so a commit or branch switch discards it. At minimum, correct the kb-tool.md sentence.\n\n## Acceptance Criteria\n- A kb-test case: rm X, restore kb.pl, lose X, add -\u003e refused.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-kbguard2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:56:20Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:29:56Z","started_at":"2026-09-23T04:01:22Z","closed_at":"2026-09-23T04:29:56Z","close_reason":"PR #179 MERGED (confirmed at 9472989), adversarially reviewed (merge); CI 14/0 including the kb job on Linux with GNU stat. ih3k: the completeness guard counts clauses PER KEY, so losing one clause of a pair that shares a key is refused. yea8: the kb rm ledger carries a stamp (sha256 + inode + ns-mtime) of the file its write published, and any rewrite outside the tool voids it. A content hash alone would not work: kb.pl is canonical, so grep -v of a clause is byte-identical to kb rm output. In the reviewer's repro the in-place rewrite even keeps the inode, so the mtime is the part doing the work. za97: the refusal no longer offers committing the loss, and the kb skill documents the guard. Follow-up: improvise-4zei (P4, case 25 cannot see a content-only stamp; case 24 still guards it).","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -563,61 +699,60 @@ {"_type":"issue","id":"improvise-gho5","title":"Rolled-back-load tests pass against a host that never ran the load; the evidence's generation-check claim is false","description":"Found by post-merge adversarial review of PR #163 (improvise-k84e), head 5fa3fed1. Low severity: a claim in the evidence is false, and nothing is broken at runtime.\n\n`context/test-evidence/command.md` says of `a_rolled_back_load_tells_nobody_and_puts_every_mirror_back`: \"the `document_generation` equality is checked first, so 'the frame was rejected' cannot be confused with 'the frame never got that far'\". It can be confused, because generation equality is exactly what holds when the frame never got that far. Nothing in the rollback test witnesses that the LoadModel inside the frame actually ran before the rollback. The control (`the_load_this_fixture_performs_rebases_every_client`) runs the load in a separate, single-command frame, so it proves the fixture CAN load, not that this frame did.\n\nCONFIRMED at 40b8913. Injected at the top of `Host::commands`: reject up front, before CheckedBatch, any multi-command frame containing a LoadModel. Both `a_rolled_back_load_tells_nobody_and_puts_every_mirror_back` and its sibling `a_rolled_back_load_leaves_no_ghost_view_and_no_false_status` PASS against a host that never applied the load, so neither `undo_frame` nor the mirror clone is exercised. Reverted.\n\nToday the tests are not vacuous: the evidence's `let _ = mirrors` injection fails them, because the current CheckedBatch lets the frame through to apply. But if the record tier ever moves up front for this frame, both tests go silently vacuous. Cheap fix: assert the asker's reply is `ServerFrame::Rejected(\u003cthe apply-time reason\u003e)`, a reason CheckedBatch cannot produce up front, or otherwise observe that the load was applied (e.g. a counter or a probe) before concluding anything from the rollback.\n\nOn gotcha 39's fixture warning: `host_over_a_loadable_document` does RENAME the active view (\"Loaded\"). I checked whether that lets the mirror assertion pass by accident. It does not: an unrestored mirror, on a view that does not exist, still diffs non-empty against the restored document, so the rename does not hide the undo fault.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-wire2","owner":"el-github@elangley.org","created_at":"2026-09-23T03:47:12Z","created_by":"agent-review-wire","updated_at":"2026-09-23T04:18:09Z","started_at":"2026-09-23T04:01:00Z","closed_at":"2026-09-23T04:18:09Z","close_reason":"PR #174 MERGED (confirmed), adversarially reviewed at 912e25a (merge). Both clause-4 rollback tests now require Rejected(ChainBroken(North)) and assert North resolves before the frame, a reason only broken_chain produces after the load is applied. Before, they passed against a host that rejected up front and never ran the load. The reviewer injected three faults (up-front rejection; load applied but mirrors not restored; mirrors restored but app not) and each fails where it should. Residual, not filed: a host that rejects up front and INVENTS ChainBroken(first named record) would pass. Counting reads of the load path on the fixture's MemoryFs would close it.","labels":["adversarial-review","pr-163","protocol","testing"],"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":"closed","priority":3,"issue_type":"bug","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T02:36:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:38:01Z","started_at":"2026-09-23T04:38:08Z","closed_at":"2026-09-23T05:38:01Z","close_reason":"Fixed in PR #193, merged as a2f3c8d. Adversarial review passed at f8f4dd5; later changes were a gh/main merge touching evidence only, plus a docs correction pointing at improvise-lxg0.","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":"closed","priority":3,"issue_type":"bug","assignee":"agent-registry","owner":"el-github@elangley.org","created_at":"2026-09-23T02:16:27Z","created_by":"Edward Langley","updated_at":"2026-09-23T05:41:35Z","started_at":"2026-09-23T04:12:57Z","closed_at":"2026-09-23T05:41:35Z","close_reason":"Resolved by PR #192 (merged after adversarial review at f44767c; later changes were evidence-only merges of gh/main). Decision: the tile bar keeps _Index/_Dim because it is the browser's only route into records mode. Follow-up: improvise-oplp (tile cursor opens on _Index).","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} -{"_type":"issue","id":"improvise-8mgz","title":"Row headers scroll out of the grid: not sticky, and the offsets cannot be computed in CSS","description":"Noticed by the improvise-tg27 agent while probing narrow widths, and correctly\nleft out of scope there.\n\n#grid is horizontally scrolled by scrollIntoView({inline:\"nearest\"}), and the\nrow-header columns are NOT sticky -- so at a narrow width the coordinate labels\nscroll off the screen and the user is looking at numbers with nothing naming the\nrows they belong to.\n\nPRE-EXISTING; improvise-tg27 did not introduce it and did not widen it. That fix\nstopped the PAGE scrolling horizontally (the app bar's reserves were the cause);\nthis is the GRID's own horizontal scroll, which is legitimate and remains.\n\nWHY IT IS NOT A CSS ONE-LINER: position:sticky on the row-header cells is the\nobvious move, and the grid's header structure is multi-level -- AxisFrame carries\n`cats` outermost-first with label TUPLES (gotcha 44), so there are N row-header\ncolumns, not one, and each needs its own `left` offset computed from the widths of\nthose to its left. The width rule has one home, improvise_protocol::header_level_widths,\nand CSS cannot read it.\n\nThat is the same shape as the sticky COLUMN headers improvise-tg27's agent\ndeclined for the same reason: getting the offsets wrong is SILENT -- the cell\nhides BEHIND the header while getBoundingClientRect still reports it in view, so\nthe existing scroll assertions stay green.\n\nSO THE TEST HAS TO ASSERT OCCLUSION, not position: that the row-header cell is the\ntopmost element at its own coordinates (elementFromPoint), not merely that its\nrect is inside the viewport. Without that, a wrong offset ships green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rx8g","title":"#hint is illegible at phone width: 1px at 520, and fixing it reopens n6t8's zero-basis argument","description":"Found and MEASURED by the improvise-tg27 agent, which first claimed its fix made\nthe hint legible, found its own spec had asserted that and PASSED, and corrected\nboth.\n\n#hint is flex:1 1 0 -- the only elastic child of the app bar (improvise-n6t8) --\nso after the header wraps it gets whatever the line leaves. Measured, settled\n(i.e. after #status composes the transport fact, not immediately after paint):\n\n 360px -\u003e 26px 520px -\u003e 1px 600px -\u003e 81px\n 700px -\u003e 317px 768px -\u003e 385px 900px -\u003e 67px 1024px -\u003e 6px\n\nNON-MONOTONIC, because it is the remainder after #view-name + #mode + #status take\ntheir reserves, and #status composes the DOCUMENT'S NAME, so the numbers are\ndocument-dependent too.\n\nWHY THIS IS NOT A ONE-LINE FIX, and the reason it is filed rather than patched:\ngiving #hint a min-width so it forces its own line DIRECTLY CONTRADICTS\nimprovise-n6t8's argument for flex:1 1 0 -- \"zero basis, absorbs the remainder,\ncontributes NOTHING TO OVERFLOW\". That basis is what stops the hint widening the\ndocument, which is exactly what improvise-tg27 then had to fix at the header\nlevel. So this reopens n6t8's reasoning and needs it argued again, not worked\naround.\n\nWORTH CONSIDERING INSTEAD OF A min-width: the hint is REFERENCE, not state (n6t8's\nown framing), so the honest answer at phone width may be to DROP it rather than\nsqueeze it -- a visible nothing beats an illegible something, and the affordance\nit carries is duplicated in the help overlay. That is a product call.\n\nMeasurements are in context/test-evidence/clients.md. Do not re-derive them\nimmediately after paint: the spec that did read 186px where the settled value is\n1px.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"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","dependencies":[{"issue_id":"improvise-t219","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:26Z","created_by":"Edward Langley","metadata":"{}"}],"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","dependencies":[{"issue_id":"improvise-q36k","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-8mgz","title":"Row headers scroll out of the grid: not sticky, and the offsets cannot be computed in CSS","description":"Noticed by the improvise-tg27 agent while probing narrow widths, and correctly\nleft out of scope there.\n\n#grid is horizontally scrolled by scrollIntoView({inline:\"nearest\"}), and the\nrow-header columns are NOT sticky -- so at a narrow width the coordinate labels\nscroll off the screen and the user is looking at numbers with nothing naming the\nrows they belong to.\n\nPRE-EXISTING; improvise-tg27 did not introduce it and did not widen it. That fix\nstopped the PAGE scrolling horizontally (the app bar's reserves were the cause);\nthis is the GRID's own horizontal scroll, which is legitimate and remains.\n\nWHY IT IS NOT A CSS ONE-LINER: position:sticky on the row-header cells is the\nobvious move, and the grid's header structure is multi-level -- AxisFrame carries\n`cats` outermost-first with label TUPLES (gotcha 44), so there are N row-header\ncolumns, not one, and each needs its own `left` offset computed from the widths of\nthose to its left. The width rule has one home, improvise_protocol::header_level_widths,\nand CSS cannot read it.\n\nThat is the same shape as the sticky COLUMN headers improvise-tg27's agent\ndeclined for the same reason: getting the offsets wrong is SILENT -- the cell\nhides BEHIND the header while getBoundingClientRect still reports it in view, so\nthe existing scroll assertions stay green.\n\nSO THE TEST HAS TO ASSERT OCCLUSION, not position: that the row-header cell is the\ntopmost element at its own coordinates (elementFromPoint), not merely that its\nrect is inside the viewport. Without that, a wrong offset ships green.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-8mgz","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:36Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rx8g","title":"#hint is illegible at phone width: 1px at 520, and fixing it reopens n6t8's zero-basis argument","description":"Found and MEASURED by the improvise-tg27 agent, which first claimed its fix made\nthe hint legible, found its own spec had asserted that and PASSED, and corrected\nboth.\n\n#hint is flex:1 1 0 -- the only elastic child of the app bar (improvise-n6t8) --\nso after the header wraps it gets whatever the line leaves. Measured, settled\n(i.e. after #status composes the transport fact, not immediately after paint):\n\n 360px -\u003e 26px 520px -\u003e 1px 600px -\u003e 81px\n 700px -\u003e 317px 768px -\u003e 385px 900px -\u003e 67px 1024px -\u003e 6px\n\nNON-MONOTONIC, because it is the remainder after #view-name + #mode + #status take\ntheir reserves, and #status composes the DOCUMENT'S NAME, so the numbers are\ndocument-dependent too.\n\nWHY THIS IS NOT A ONE-LINE FIX, and the reason it is filed rather than patched:\ngiving #hint a min-width so it forces its own line DIRECTLY CONTRADICTS\nimprovise-n6t8's argument for flex:1 1 0 -- \"zero basis, absorbs the remainder,\ncontributes NOTHING TO OVERFLOW\". That basis is what stops the hint widening the\ndocument, which is exactly what improvise-tg27 then had to fix at the header\nlevel. So this reopens n6t8's reasoning and needs it argued again, not worked\naround.\n\nWORTH CONSIDERING INSTEAD OF A min-width: the hint is REFERENCE, not state (n6t8's\nown framing), so the honest answer at phone width may be to DROP it rather than\nsqueeze it -- a visible nothing beats an illegible something, and the affordance\nit carries is duplicated in the help overlay. That is a product call.\n\nMeasurements are in context/test-evidence/clients.md. Do not re-derive them\nimmediately after paint: the spec that did read 186px where the settled value is\n1px.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T23:07:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:07:55Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-rx8g","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-t14d","title":"The TUI title bar drops '?:help :q quit' when the document name is long: fill_line gives the left segment priority","description":"Found by the improvise-p8ls agent while proving that ticket invalid. The\naffordance it was filed about DOES exist -- and it is the first thing sacrificed\nwhen the title bar overflows, which happens at ordinary widths.\n\nsrc/draw.rs:172:\n fn fill_line(left: String, right: \u0026str, width: u16) -\u003e String {\n let pad = \" \".repeat((width as usize).saturating_sub(left.len() + right.len()));\n format!(\"{left}{pad}{right}\")\n }\nVerified by the orchestrator. On overflow it simply concatenates, and ratatui\nclips the RIGHT -- which is where \" ?:help :q quit \" lives.\n\nMEASURED against the real draw() through a TestBackend:\n w= 36 | improvise · Sales (sales.improv)| affordance GONE\n w= 44 ... ?:help| half gone\n w= 52 ... ?:help :q qui| truncated\n w= 60 intact\n\nBUT WIDTH IS NOT THE REAL VARIABLE -- TITLE LENGTH IS, and it is unbounded:\n w=100 | improvise · Quarterly Regional Sales Analysis\n (quarterly-regional-sales-2026.improv) ?:help :q|\nAn ordinary document name plus filename destroys the escape hatch AT 100 COLUMNS,\nin Normal, RecordsNormal and TileSelect.\n\nSO THE PRIORITY IS BACKWARDS: the left segment (decoration) has unconditional\npriority over the escape hatch (the thing a stuck user needs). That is the bug.\n\nTWO SMALLER FAULTS IN THE SAME THREE LINES:\n 1. left.len() is BYTES, and the title contains \"·\" (2 bytes in UTF-8), so the\n padding is always one column short and a non-ASCII document name shifts the\n right segment further left. Note src/ui/text.rs::truncate now exists as the\n one display-width-safe truncator (improvise-8l0c) -- fill_line predates it\n and does not use it.\n 2. There is no truncation strategy at all, so the failure is a MID-TOKEN clip\n (\"?:\") rather than a graceful drop of the whole segment.\n\nNOT A DUPLICATE of improvise-tg27, which is the WEB client's grid columns and\nweb/style.css flex/overflow. Different front end, different file, different\nmechanism. bd search \"narrow\" and \"title bar\" found nothing covering this.\n\nWHEN FIXING: give the right segment priority, truncate the left with\nsrc/ui/text.rs::truncate (display cells, never bytes), and test through\nTestBackend at the widths above -- a test at 80 columns with a short fixture name\ncannot see this.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T22:59:23Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:33Z","started_at":"2026-09-23T01:18:16Z","closed_at":"2026-09-23T02:00:33Z","close_reason":"PR #145. draw.rs::fill_line takes a Keeps parameter, because the three rows sharing a line disagree about which side yields: the title bar keeps the RIGHT (the ?:help/:q escape hatch outranks an unbounded document name), while minibuffer_line and draw_status keep the LEFT (the user's own typing outranks a view badge). NOT PREDICTED BY THE TICKET: the byte-padding fault fires on a PURE-ASCII document name, because the separator dot is in draw_title's own format string -- two bytes, one cell -- so every document's right segment sat one cell in from the edge. The ticket framed it as a non-ASCII-name problem. Widths now go through ui::text::truncate, never str::len. (--force: agent-tui handed back.)","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-56qu","title":"site/ is a tracked build artifact that silently lags context/, and nothing compares them","description":"Found by the improvise-xgra agent (PR #133) by accident: it ran\nscripts/build-doc-site.mjs with no argument, which defaults to the TRACKED\nsite/docs, and the regeneration produced 26 MODIFIED FILES plus THREE PAGES THAT\nDO NOT EXIST IN THE TREE AT ALL:\n\n adr/004-faces-and-themes\n code/cli\n test-evidence/cli\n\nAll three are documents added TODAY (ADR 004 in PR #122/#132, the CLI docs in PR\n#131). So the committed site/ lags context/ by at least a day, and NOTHING IN CI\nCOMPARES THEM. The build job assembles into its OWN output directory, so the\ntracked copy is never checked against the source it was generated from.\n\nTWO SEPARATE PROBLEMS, and they want different answers:\n\n1. site/ IS A TRACKED BUILD ARTIFACT THAT SILENTLY LAGS. Either CI regenerates\n and compares (failing when they differ), or site/ stops being tracked, or it is\n explicitly declared a periodic snapshot with the staleness accepted. The third\n is a real option -- improvise-oujb's agent noted site/docs has been touched by\n two commits ever -- but it should be a DECISION rather than the current\n accident, because a reader cannot tell a snapshot from a stale artifact.\n\n2. RUNNING THE DOC-SITE BUILD DIRTIES THE TREE, which is a trap for any agent\n that runs it to verify the SECTIONS gate. The xgra agent reverted its 26 files;\n the next one may not notice. Note the gate itself is worth keeping: running\n build-doc-site.mjs exits 0 and prints no \"neither published nor a named\n omission\", which is a cheap POSITIVE check that a touched context/ file is\n published -- not only a trap to avoid.\n\nRELATED, ALREADY KNOWN: improvise-oujb's agent reported that site/ is partially\ntracked (27 files under site/docs/) and is NOT in .gitignore, so following the\nREADME's own instructions dirties the tree. Same root, reported twice by two\nagents on different days, which is itself the argument for deciding it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T21:04:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T21:04:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1jay","title":"context/code/protocol.md:470 says no client composes CellId::Create; gcvb closed that and bd9o closed the route","description":"Reported by the improvise-bd9o agent (PR #128) and deliberately not fixed there --\nnot its file, and stale independently of that branch.\n\ncontext/code/protocol.md:470 says \"No client composes it yet\" about\nCellId::Create, and cites improvise-gcvb as an open gap. That predates gcvb\nlanding: a client DOES compose it, in commit.\n\nAnd improvise-bd9o has since changed the picture again from the other side: it\ntook away the DOOR. edit_or_drill now declines on a records grid before reaching\nthe CellId arms at all, so CellId::Create is no longer reachable from a key --\nwhich is why improvise-x0xd exists (create_column is now explicitly dead rather\nthan accidentally so).\n\nSO ONE SENTENCE IS OWED, AND IT HAS TWO CLAUSES: gcvb closed the compose gap in\ncommit, and bd9o has since closed the key route. Writing only the first would\nleave a reader believing a key reaches it.\n\nThis is the same class as the two prose passages Codex caught on PR #128 -- a\nfact stated in more than one place, where the place that moved was not the place\nsomeone was editing. Cheap to fix, and worth doing while the two commits that\nfalsified it are still fresh enough to cite.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T08:16:51Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-x0xd","title":"The browser records-mode creating columns are explicitly dead and could be live","description":"improvise-gcvb built `create_column` — the one records-mode commit a thin client\ncan compose, since a `CellId::Create` commits the typed NAME as one existing\nModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and\nwrites no cell. It has never been reachable.\n\nThe only door into RecordsEditing is `edit-or-drill`, whose `CellId::Create` arm\nreturned None; `o` is declined at its `add-record-row` step. So `commit`'s\n`CellId::Create` branch was dead on arrival — an accident of two unrelated arms\nrather than a decision.\n\nimprovise-bd9o made that deadness EXPLICIT rather than closing it: the records\nguard now sits at the entrance of `edit_or_drill`, so every records column\ndeclines, and the `CellId::Create` arm declines too (\"creating a column is not\navailable in the browser yet\") instead of returning None. That was the ticket's\nown stated acceptance criterion and the right scope for a P1 about silence.\n\nWHAT THIS TICKET IS: open the editor on `CellId::Create` while still refusing\nthe coordinate and measure columns. That means the entrance guard becomes a\nper-column decision again, which is exactly the shape bd9o argued against for\nthe silence case — so it needs its own evidence rather than a revert.\n\nTwo things to answer first, both already argued in gcvb and clients.md:\n\n - The +cat half MUST NOT ADVANCE. An inserted column renumbers every index to\n its right and the advance was computed from the pre-commit layout (gotcha\n 23), so a client that advanced anyway lands one column off, silently.\n `create_column` already does this; the test does not exist end to end.\n - AddCategory can be refused by the 12-category limit and sets abort_effects,\n so the mode change goes FIRST, where an abort cannot reach it (gotcha 21).\n Also already in `create_column`.\n\nSo the code is written and argued; what is missing is a door and the evidence\nthat the door is safe. Worth doing because it is the ONE thing a browser could\ndo in records mode and currently cannot.\n\nACCEPTANCE CRITERIA\n\n - Pressing `i`/`a` on a records `+` or `+cat` column opens the editor in the\n browser, and Enter commits through the existing `create_column`.\n - Every OTHER records column still declines out loud (the bd9o property must\n not regress) — assert it by walking all columns, as\n `the_tile_bar_route_into_records_mode_does_not_strand_the_user` does.\n - An end-to-end test through a real Host proves +cat does not advance, by\n injecting the advancing rival and watching it land one column off.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:57:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:57:39Z","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-56qu","title":"site/ is a tracked build artifact that silently lags context/, and nothing compares them","description":"Found by the improvise-xgra agent (PR #133) by accident: it ran\nscripts/build-doc-site.mjs with no argument, which defaults to the TRACKED\nsite/docs, and the regeneration produced 26 MODIFIED FILES plus THREE PAGES THAT\nDO NOT EXIST IN THE TREE AT ALL:\n\n adr/004-faces-and-themes\n code/cli\n test-evidence/cli\n\nAll three are documents added TODAY (ADR 004 in PR #122/#132, the CLI docs in PR\n#131). So the committed site/ lags context/ by at least a day, and NOTHING IN CI\nCOMPARES THEM. The build job assembles into its OWN output directory, so the\ntracked copy is never checked against the source it was generated from.\n\nTWO SEPARATE PROBLEMS, and they want different answers:\n\n1. site/ IS A TRACKED BUILD ARTIFACT THAT SILENTLY LAGS. Either CI regenerates\n and compares (failing when they differ), or site/ stops being tracked, or it is\n explicitly declared a periodic snapshot with the staleness accepted. The third\n is a real option -- improvise-oujb's agent noted site/docs has been touched by\n two commits ever -- but it should be a DECISION rather than the current\n accident, because a reader cannot tell a snapshot from a stale artifact.\n\n2. RUNNING THE DOC-SITE BUILD DIRTIES THE TREE, which is a trap for any agent\n that runs it to verify the SECTIONS gate. The xgra agent reverted its 26 files;\n the next one may not notice. Note the gate itself is worth keeping: running\n build-doc-site.mjs exits 0 and prints no \"neither published nor a named\n omission\", which is a cheap POSITIVE check that a touched context/ file is\n published -- not only a trap to avoid.\n\nRELATED, ALREADY KNOWN: improvise-oujb's agent reported that site/ is partially\ntracked (27 files under site/docs/) and is NOT in .gitignore, so following the\nREADME's own instructions dirties the tree. Same root, reported twice by two\nagents on different days, which is itself the argument for deciding it.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T21:04:54Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:42:08Z","closed_at":"2026-09-25T04:42:08Z","close_reason":"Resolved by PR #312 (merged 3ad7d910): site/ is no longer tracked; every reader builds it from source (improvise-1xfb.4)","dependencies":[{"issue_id":"improvise-56qu","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:44Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1jay","title":"context/code/protocol.md:470 says no client composes CellId::Create; gcvb closed that and bd9o closed the route","description":"Reported by the improvise-bd9o agent (PR #128) and deliberately not fixed there --\nnot its file, and stale independently of that branch.\n\ncontext/code/protocol.md:470 says \"No client composes it yet\" about\nCellId::Create, and cites improvise-gcvb as an open gap. That predates gcvb\nlanding: a client DOES compose it, in commit.\n\nAnd improvise-bd9o has since changed the picture again from the other side: it\ntook away the DOOR. edit_or_drill now declines on a records grid before reaching\nthe CellId arms at all, so CellId::Create is no longer reachable from a key --\nwhich is why improvise-x0xd exists (create_column is now explicitly dead rather\nthan accidentally so).\n\nSO ONE SENTENCE IS OWED, AND IT HAS TWO CLAUSES: gcvb closed the compose gap in\ncommit, and bd9o has since closed the key route. Writing only the first would\nleave a reader believing a key reaches it.\n\nThis is the same class as the two prose passages Codex caught on PR #128 -- a\nfact stated in more than one place, where the place that moved was not the place\nsomeone was editing. Cheap to fix, and worth doing while the two commits that\nfalsified it are still fresh enough to cite.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T08:16:51Z","created_by":"Edward Langley","updated_at":"2026-09-22T08:16:51Z","dependencies":[{"issue_id":"improvise-1jay","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:02Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-x0xd","title":"The browser records-mode creating columns are explicitly dead and could be live","description":"improvise-gcvb built `create_column` — the one records-mode commit a thin client\ncan compose, since a `CellId::Create` commits the typed NAME as one existing\nModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and\nwrites no cell. It has never been reachable.\n\nThe only door into RecordsEditing is `edit-or-drill`, whose `CellId::Create` arm\nreturned None; `o` is declined at its `add-record-row` step. So `commit`'s\n`CellId::Create` branch was dead on arrival — an accident of two unrelated arms\nrather than a decision.\n\nimprovise-bd9o made that deadness EXPLICIT rather than closing it: the records\nguard now sits at the entrance of `edit_or_drill`, so every records column\ndeclines, and the `CellId::Create` arm declines too (\"creating a column is not\navailable in the browser yet\") instead of returning None. That was the ticket's\nown stated acceptance criterion and the right scope for a P1 about silence.\n\nWHAT THIS TICKET IS: open the editor on `CellId::Create` while still refusing\nthe coordinate and measure columns. That means the entrance guard becomes a\nper-column decision again, which is exactly the shape bd9o argued against for\nthe silence case — so it needs its own evidence rather than a revert.\n\nTwo things to answer first, both already argued in gcvb and clients.md:\n\n - The +cat half MUST NOT ADVANCE. An inserted column renumbers every index to\n its right and the advance was computed from the pre-commit layout (gotcha\n 23), so a client that advanced anyway lands one column off, silently.\n `create_column` already does this; the test does not exist end to end.\n - AddCategory can be refused by the 12-category limit and sets abort_effects,\n so the mode change goes FIRST, where an abort cannot reach it (gotcha 21).\n Also already in `create_column`.\n\nSo the code is written and argued; what is missing is a door and the evidence\nthat the door is safe. Worth doing because it is the ONE thing a browser could\ndo in records mode and currently cannot.\n\nACCEPTANCE CRITERIA\n\n - Pressing `i`/`a` on a records `+` or `+cat` column opens the editor in the\n browser, and Enter commits through the existing `create_column`.\n - Every OTHER records column still declines out loud (the bd9o property must\n not regress) — assert it by walking all columns, as\n `the_tile_bar_route_into_records_mode_does_not_strand_the_user` does.\n - An end-to-end test through a real Host proves +cat does not advance, by\n injecting the advancing rival and watching it land one column off.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:57:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:57:39Z","dependencies":[{"issue_id":"improvise-x0xd","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-vgp4","title":"A refusal written while a minibuffer is open may never be drawn: draw_bottom_bar paints one or the other","description":"Found by the improvise-55l5 agent (PR #126), which wrote the message and stopped\nat the point where drawing it is a different question.\n\nimprovise-55l5 made a bound key that cannot construct its command SAY SO, via\nset_status. The class that reaches a user is \"no cell at cursor\" from six\nregistrations that read ctx.cell_key(), and the worst case is commit-cell-edit:\nthe user is in Editing, has typed a value, presses Enter, and the batch is\ndiscarded.\n\nTHE MESSAGE IS NOW WRITTEN AND MAY NOT BE DRAWN. gotcha 21 records that\ndraw_bottom_bar renders the MINIBUFFER INSTEAD OF the status line, which is\nexactly the rendering trap that made an aborted batch appear to do nothing at\nall. A refusal from commit-cell-edit leaves the user in Editing -- a minibuffer\nmode -- so the status it just wrote is plausibly invisible.\n\nTHIS IS THE SAME SHAPE AS improvise-j6x8 one layer over: a refusal that is\ncorrect, tested at the effect, and discarded by the surface that was supposed to\nshow it. Compare gotcha 21's own remedy -- tidy-up goes at the HEAD of a batch\nwhere an abort cannot reach it -- and note that remedy is about ORDERING within a\nbatch, not about which of two things draw_bottom_bar draws.\n\nTO ANSWER: does draw_bottom_bar draw a status while a minibuffer is open, and if\nnot, what should it do when the status is a REFUSAL of the very thing the\nminibuffer is for. A refusal the user cannot see is not a refusal\n(doctrine(a_refusal_the_headless_path_discards_is_not_a_refusal), the TUI twin).\n\nReproduce before fixing: the agent did not chase this and it may already hold --\ngotcha 21's own fix put mode restoration at the head of several batches.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:56Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:31Z","started_at":"2026-09-23T01:34:40Z","closed_at":"2026-09-23T02:00:31Z","close_reason":"STALE-OPEN, nothing to fix. Fixed by 94a3d17 'fix: the minibuffer row carries a pending status message (improvise-mai9)', which landed 2026-09-20 -- TWO DAYS BEFORE vgp4 was filed. Verified with git merge-base --is-ancestor. Driven as the real user path (empty Region on Row, then i/4/2/Enter, giving Editing plus 'commit-cell-edit: no cell at cursor'), draw_bottom_bar renders the refusal at 30/40/50/60/80 columns; minibuffer_line composes BOTH segments and does not paint one or the other. WHY IT READ AS LIVE, and this is the transferable part: gotcha 21 still describes the PRE-mai9 behaviour in the past tense, because that is the stranding argument it is making. The trap is a correct historical narrative read as a current claim, not a stale gotcha. (Closed with --force: agent-tui handed back and stopped, so the claim is abandoned.)","labels":["tui"],"comments":[{"id":"f79e6ebc-7f67-5135-8f01-9c241e06704e","issue_id":"improvise-vgp4","author":"agent-tui","text":"DOES NOT REPRODUCE at a9cad15 (and not at any commit since 2026-09-20). The\nticket said it might already hold; it does, and the fixing commit is 94a3d17\n\"fix: the minibuffer row carries a pending status message (improvise-mai9)\",\nconfirmed an ancestor of a9cad15 by `git merge-base --is-ancestor` (exit 0).\nmai9 landed TWO DAYS BEFORE this ticket was filed.\n\n`draw_bottom_bar` does NOT paint \"one or the other\". It calls `minibuffer_line`,\nwhich composes BOTH: prompt + buffer + cursor on the left, the pending\n`status_msg` right-aligned. The premise in the ticket title is what gotcha 21\nrecords about the code BEFORE mai9, and gotcha 21 still describes it in the past\ntense for the stranding argument it is making -- which is how this read as live.\n\nDriven as the real user path rather than by inspection, on the worst case the\nticket names (commit-cell-edit, the user in Editing with a typed value):\n\n App with an empty `Region` on Row, so `layout.cell_key(0,0)` is None\n i, '4', '2', Enter\n -\u003e mode stays Editing { minibuf: \"edit\" }, status_msg\n \"commit-cell-edit: no cell at cursor\"\n\nand `draw_bottom_bar` rendered into a TestBackend at five widths:\n\n w=30 \"edit: 42▌ commit-cell-edit: …\"\n w=40 \"edit: 42▌ commit-cell-edit: no cell at…\"\n w=50 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n w=60 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n w=80 \"edit: 42▌ commit-cell-edit: no cell at cursor \"\n\nSo the refusal reaches the screen at every width. The ellipsis at 30 and 40 is\nimprovise-t14d's truncation (PR #145) landing in the same row; before it the\nnarrow cases were a hard clip at the same point, so the visible content is\nunchanged and only the mid-token cut is gone.\n\nThe ticket's second question -- \"what should it do when the status is a REFUSAL\nof the very thing the minibuffer is for\" -- is answered by the composition\nrather than by a special case: the refusal is never traded against the prompt,\nonly against the padding between them. At widths where even that runs out, the\nLEFT wins (improvise-t14d's `Keeps::Left` at this call site, which is what this\nrow already did), so a narrow terminal truncates the message rather than the\ntext the user is editing. That is a deliberate choice and is argued in\ncontext/code/tui.md; if it is the wrong one for a refusal specifically, that is\na new ticket about severity, not this one -- and improvise-udt2 is already open\non \"a status message carries no severity\".\n\nReported by Claude (Opus 5), an agent in Edward Langley's session. Closing is\nthe orchestrator's call; I have closed nothing.","created_at":"2026-09-23T01:36:34Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-b8ng","title":"view_frame cannot tell an appended row from a renumbered one, so every append resets the cursor","description":"Found by the improvise-o9uq agent (PR #126) while fixing the cursor placement.\nFiled rather than fixed: it is the SECOND cause of o9uq and is not the one that\nmade o destructive.\n\nview_frame hashes entries.len() AND every entry, so it cannot distinguish \"rows\nwere appended\" from \"rows were renumbered\". Gotcha 24 invalidates the cursor by\nOBSERVING the frame, which is deliberately total over EffectKind::Model(_) and is\nthe reason there is no hand-kept table of geometry effects -- so every append\nthrows the cursor to the origin, although nothing above the new row moved.\n\nWHY IT IS NOT URGENT NOW: improvise-o9uq closed the destructive path by having\nadd-record-row place its own cursor AFTER the SetCell, so the invalidation is\nimmediately overwritten by a correct placement. The remaining cost is any OTHER\nappend-shaped change whose command does not place a cursor.\n\nWHY IT IS DELICATE, and the reason this is a ticket rather than a patch: the\nobservation's totality is the property gotcha 24 was rewritten to get. A frame\nhash that can tell append from renumber is a SMARTER observation, and a smarter\nobservation is one more thing that can be subtly wrong in a direction nothing\ntests -- gotcha 24 already records that doing only half of the invalidation is\nSTRICTLY WORSE THAN DOING NOTHING, because the diff then believes every client\nholds origin-positioned content. Do not optimise this without a test that\ndistinguishes the two cases by construction.\n\nNote the adjacent gap already filed: improvise-9c9o (a geometry effect\nimmediately after a mid-batch SwitchView skips the check entirely).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:55Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-n8vh","title":"The category and view panels never truncate, so a long name overwrites the panel border","description":"The category and view panels never truncate a row, so a name longer than the\npanel overwrites its right border.\n\nFound while fixing improvise-8l0c, which was the *wrong* truncation in\n`src/ui/formula_panel.rs`. These two are a different bug: no truncation at all.\n\n`src/ui/category_panel.rs::render_item` writes `format!(\"{indicator} {name} ({item_count})\")`\nstraight through `Buffer::set_string`, and `src/ui/view_panel.rs::render_item`\ndoes the same with `format!(\"{prefix}{}\", summary.name)`. `set_string` clips at\nthe *buffer* edge, not at the panel's `inner` rect, so a long category or view\nname draws over the block's right border character and, in a narrow terminal,\nruns to the screen edge. No panic.\n\n8l0c left the tool in place: `src/ui/text.rs::truncate` is the shared\ndisplay-width-safe truncator and is what these two want, with the axis tag /\naxis summary suppressed rather than pushed off. Both files already measure the\ntag's offset in cells since 8l0c, so the arithmetic is there.\n\nEvidence to write: `context/test-evidence/tui.md` already carries the\nobservation under the 8l0c section, keyed by\n`ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name`.\nA regression test should assert the border column still holds the border glyph.\n\nFiled by Claude (Opus 5), an agent in Edward Langley's session.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:28:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:28:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-b8ng","title":"view_frame cannot tell an appended row from a renumbered one, so every append resets the cursor","description":"Found by the improvise-o9uq agent (PR #126) while fixing the cursor placement.\nFiled rather than fixed: it is the SECOND cause of o9uq and is not the one that\nmade o destructive.\n\nview_frame hashes entries.len() AND every entry, so it cannot distinguish \"rows\nwere appended\" from \"rows were renumbered\". Gotcha 24 invalidates the cursor by\nOBSERVING the frame, which is deliberately total over EffectKind::Model(_) and is\nthe reason there is no hand-kept table of geometry effects -- so every append\nthrows the cursor to the origin, although nothing above the new row moved.\n\nWHY IT IS NOT URGENT NOW: improvise-o9uq closed the destructive path by having\nadd-record-row place its own cursor AFTER the SetCell, so the invalidation is\nimmediately overwritten by a correct placement. The remaining cost is any OTHER\nappend-shaped change whose command does not place a cursor.\n\nWHY IT IS DELICATE, and the reason this is a ticket rather than a patch: the\nobservation's totality is the property gotcha 24 was rewritten to get. A frame\nhash that can tell append from renumber is a SMARTER observation, and a smarter\nobservation is one more thing that can be subtly wrong in a direction nothing\ntests -- gotcha 24 already records that doing only half of the invalidation is\nSTRICTLY WORSE THAN DOING NOTHING, because the diff then believes every client\nholds origin-positioned content. Do not optimise this without a test that\ndistinguishes the two cases by construction.\n\nNote the adjacent gap already filed: improvise-9c9o (a geometry effect\nimmediately after a mid-batch SwitchView skips the check entirely).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T07:40:55Z","created_by":"Edward Langley","updated_at":"2026-09-22T07:40:55Z","dependencies":[{"issue_id":"improvise-b8ng","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-n8vh","title":"The category and view panels never truncate, so a long name overwrites the panel border","description":"The category and view panels never truncate a row, so a name longer than the\npanel overwrites its right border.\n\nFound while fixing improvise-8l0c, which was the *wrong* truncation in\n`src/ui/formula_panel.rs`. These two are a different bug: no truncation at all.\n\n`src/ui/category_panel.rs::render_item` writes `format!(\"{indicator} {name} ({item_count})\")`\nstraight through `Buffer::set_string`, and `src/ui/view_panel.rs::render_item`\ndoes the same with `format!(\"{prefix}{}\", summary.name)`. `set_string` clips at\nthe *buffer* edge, not at the panel's `inner` rect, so a long category or view\nname draws over the block's right border character and, in a narrow terminal,\nruns to the screen edge. No panic.\n\n8l0c left the tool in place: `src/ui/text.rs::truncate` is the shared\ndisplay-width-safe truncator and is what these two want, with the axis tag /\naxis summary suppressed rather than pushed off. Both files already measure the\ntag's offset in cells since 8l0c, so the arithmetic is there.\n\nEvidence to write: `context/test-evidence/tui.md` already carries the\nobservation under the 8l0c section, keyed by\n`ui::category_panel::tests::the_axis_tag_sits_beside_a_multibyte_category_name`.\nA regression test should assert the border column still holds the border glyph.\n\nFiled by Claude (Opus 5), an agent in Edward Langley's session.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:28:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:28:47Z","dependencies":[{"issue_id":"improvise-n8vh","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-dw33","title":"Truncate the build stamp to 8 characters -- but only when it is a SHA, never a tag","description":"USER 2026-09-22: \"the build number in the header should be truncated to like 8\ndigits, `ee0f57d227e8d9601a7353b23704de78df9a75a8` is way too much text\"\n\nWHERE IT IS: web/index.html:43 carries\n`\u003cspan title=\"Build this page was assembled from\" id=\"build-version\"\u003eunstamped\u003c/span\u003e`,\nand web/assemble.sh:88 substitutes the token into it, gated by\nscripts/assemble-stamp.sh against the pattern `id=\"build-version\"\u003e${token}\u003c`.\n\n8 IS ALSO THE REPO'S OWN CONVENTION: scripts/pr-reviews prints `.commit.oid[0:8]`,\nand the orchestrator's prose uses 8 throughout. So this is consistency, not taste.\n\nTHE TRAP, AND IT IS THE WHOLE TICKET: THE TOKEN IS NOT ALWAYS A SHA. Pages passes\n$GITHUB_SHA; a RELEASE passes the tag (improvise-oujb). Truncating blindly turns\n`v0.1.0-rc2` into `v0.1.0-r`, which is not a shorter version -- it is a WRONG one,\nand it is wrong in the one build a user is most likely to quote in a bug report.\n\nSo truncate ONLY when the token is a 40-hex SHA. That test already exists in this\nrepo: scripts/build-doc-site.mjs:21 is\n`const ref = /^[0-9a-f]{40}$/.test(token) ? token : \"main\";`\n-- SHARE the rule rather than writing a third copy of it, or at minimum point at\nthat line so the two cannot drift. Two answers to \"is this token a SHA\" is the\ndisease design-principles section 5 names.\n\nKEEP THE FULL VALUE REACHABLE. The 40-char SHA renders today only because CSS\n`text-overflow` clamps it, and the #121 agent measured that selecting the text\nstill copies the WHOLE SHA. Truncating the text loses that unless the full value\ngoes somewhere -- the `title` attribute already exists and is the obvious home,\nor a `data-` attribute. A build id you cannot copy in full is a build id that\ncannot be pasted into an issue.\n\nTHE GATE MUST MATCH WHAT IS DISPLAYED. assemble.sh:88 passes `${token}` as the\nexpected pattern; if the displayed text is truncated, that argument becomes the\ntruncated form. Getting it wrong REFUSES THE ASSEMBLY rather than shipping an\nunstamped page, so it fails safe -- but it fails loudly during a release, so get\nit right rather than relying on that. scripts/assemble-stamp.sh verifies each of\nits five pairs independently (improvise-x4wv), so a wrong pattern names itself.\n\nTEST IT AT BOTH SHAPES. scripts/assemble-stamp-test.sh is the check_the_checker\nand already runs 9 checks; a tag token and a SHA token are two cases and only one\nof them is the interesting one. Inject a missed substitution at each shape.\n\nSEQUENCING: branch ui/page-menu is in flight. It should not touch\nweb/index.html:43 or web/assemble.sh, but confirm before starting.","notes":"CLOSED BY USER RULING 2026-09-22, as already answered by PR #121.\n\nThe user proposed the CSS form -- \"We could also just do a max-width there, plus\nellipsis text-truncation in css\" -- and on being shown that it is already live,\nruled: leave it.\n\nWHAT IS ALREADY ON MAIN (web/style.css:92, landed with improvise-oujb / PR #121):\n #build-version { flex: none; max-width: 14ch; overflow: hidden;\n text-overflow: ellipsis; white-space: nowrap; ... }\nwith a comment stating the property that makes it the right choice: A TAG FITS\nWHOLE, and a 40-char SHA truncates to a prefix long enough to name a build while\nselecting the text still copies the whole thing. Measured by the #121 agent in\nChromium: tag renders whole (scrollWidth 69 = clientWidth 69), SHA renders 13\nchars + ellipsis, header does not overflow at either.\n\nWHY THIS IS THE BETTER ANSWER AND NOT JUST THE CHEAPER ONE: clamping by WIDTH\ncannot mangle a tag, where truncating by CHARACTER COUNT turns v0.1.0-rc2 into\nv0.1.0-r -- a wrong version rather than a short one, in exactly the build a user\nwould quote. The CSS form sidesteps the whole SHA-vs-tag test this ticket was\nfiled to handle.\n\nTHE COST, ACCEPTED AND RECORDED so it is not rediscovered as a defect: the shown\nprefix is VIEWPORT-DEPENDENT, so two people may quote different prefixes of one\nSHA, and neither matches the 8-character form scripts/pr-reviews and the rest of\nthis repo's tooling uses. If that ever bites -- a bug report quoting a prefix\nnobody can match -- the fix is build-side truncation gated on\n/^[0-9a-f]{40}$/ (the test already exists at scripts/build-doc-site.mjs:21),\nwith the gate pattern in web/assemble.sh:88 changed to match the displayed form.\nReopen rather than re-derive.\n\nNOTE the user was most likely looking at a Pages build predating the #121 merge;\nthe deploy for it went out with the push of ee0f57d.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:15:12Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:16:24Z","closed_at":"2026-09-22T06:16:24Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:11:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l2b8","title":"ADR 005: exact decimal arithmetic for formulas, on Scheme numeric-tower promotion rules","description":"USER 2026-09-22: \"Formulas should default to decimal math (ideally following\nScheme's numeric tower rules for promotion).\"\n\nSEPARATE FROM THE SYMPTOM THAT PROMPTED IT. The \"useless numbers\" the user saw\nare a FORMATTING defect (improvise-e0ex): number_format defaults to \",.0\", zero\ndecimal places, so a 0.66 ratio draws as \"1\". Exact arithmetic would not change\nthat by one character. This ticket is the numerics change on its own merits, and\nthose merits are real -- which is why it is filed rather than folded in.\n\nWHY IT IS WORTH DOING INDEPENDENTLY:\n - A spreadsheet that answers 0.1 + 0.2 with 0.30000000000000004 is wrong in the\n way users notice and never forgive. CellValue::Number(f64) does.\n - gotcha 18 records a REAL BUG this would have prevented: f64 addition is not\n associative, so SUM/AVG drifted in the last bits when DataStore iteration\n order changed. Min/Max/Count were unaffected because they are order-free.\n EXACT RATIONALS ARE ASSOCIATIVE -- the whole class disappears rather than\n being fixed by pinning iteration order.\n - gotcha 17's FLOAT_EQ_EPSILON (1e-10, shared by =, != and the div/0 guard in\n both evaluators) exists BECAUSE of binary floats. Exact arithmetic makes\n equality exact and the epsilon unnecessary -- six uses, one constant, gone.\n\nWHAT THE TOWER MEANS HERE. Scheme's rule: exact integer -\u003e exact rational -\u003e\ninexact real, with operations preserving exactness where they can, and `/` on two\nexacts yielding an exact rational rather than a float. So Profit / Revenue is an\nexact ratio, rounded only at display. Decide explicitly what makes a value\nINEXACT and whether improvise has any such operation today (POW with a\nnon-integer exponent is the obvious candidate; there are no transcendentals).\n\nSEVEN THINGS TO ANSWER BEFORE WRITING CODE:\n 1. WHAT IS THE STORED TYPE? CellValue::Number(f64) is in improvise-core and\n reaches everything. A new arm, a replaced arm, or a wrapper -- each has a\n different blast radius, and CellValue is Serialize/Deserialize and crosses\n the wire.\n 2. DENOMINATOR GROWTH. Summing thousands of exact rationals with unrelated\n denominators is how exact arithmetic becomes slow and memory-hungry. Say\n what bounds it. Measure at the 18,000-cell and 90,000-cell sizes gotcha 24\n already uses, so the numbers are comparable.\n 3. THE WASM BUNDLE. improvise-core is a browser-bundle crate. A bignum\n dependency is bundle weight on every page load; check with `cargo tree\n --edges normal` per crate (the default output includes DEV edges and reads\n like a leak -- see the Wasm section of repo-map).\n 4. .improv ROUND-TRIP. The format stores numbers as TEXT, so exact decimals\n round-trip BETTER than f64 does today. That is a point in favour, but the\n grammar and the round-trip tests must be re-read: \"1/3\" and \"0.333...\" are\n not the same value and the format has to say which it holds.\n 5. gp-tree IS NOT IN SCOPE. It fits over f32 feature matrices and its\n tests/scoring_known_answers.rs pins f32 BIT PATTERNS plus an RNG-call-order\n witness (gotcha 37c). It must keep binary floats; the seam is\n improvise-gp::dataset, and what crosses it needs stating.\n 6. WHAT DOES A THIN CLIENT SEE? CellDisplay carries text today, so a browser\n may be unaffected -- confirm rather than assume, because if a number crosses\n as a number, its type is a protocol change.\n 7. MIGRATION. Every existing .improv must load unchanged. Exactness is a\n property the current format does not record, so decide what an existing\n decimal literal BECOMES on load, and whether that is a behaviour change to\n existing documents.\n\nTHIS IS AN ADR, NOT A TICKET, and should be one before any code -- the type\nchoice reaches improvise-core, the wire, the grammar and the browser bundle at\nonce. Note ADR 004 is being drafted for the face system, so this is 005.\n\nDO NOT START BY REPLACING f64. Start by writing down what exactness means in a\npivot table, where most cells are user-entered decimals and most operations are\nSUM.","status":"in_progress","priority":3,"issue_type":"task","assignee":"agent-adr005","owner":"el-github@elangley.org","created_at":"2026-09-22T06:11:47Z","created_by":"Edward Langley","updated_at":"2026-09-25T04:45:31Z","started_at":"2026-09-25T04:45:31Z","lease_expires_at":"2026-09-25T04:50:31Z","heartbeat_at":"2026-09-25T04:45:31Z","dependencies":[{"issue_id":"improvise-l2b8","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:18Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"da77dcfd-15a1-5256-9e46-bb12f61582d8","issue_id":"improvise-l2b8","author":"Edward Langley","text":"Evidence from the ADR 009 agent (PR #309, examples/retraction_drift.rs, M4 Max): TODAY's eager f64 SUM over 18k cells is up to 5.8e-10 from the exact decimal sum, above FLOAT_EQ_EPSILON (1e-10) at 7 of 8 checkpoints, so IF(SUM(..) = typed value) can already be wrong. And incrementally maintained SUM/AVG (ADR 009) cannot be made bit-equal to eager in f64 at all, so ADR 009 gates maintained Sum/Avg on this ADR.","created_at":"2026-09-25T03:19:21Z"},{"id":"68db5de9-d990-5a6a-8e9d-d49017a3bc8e","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25 (asked 'whether division degrading into floats for 10/3 is ideal'), verbatim: 'I think (d): exact fractions with a size limit. should be added.' -\u003e ADR 005 question 2 gains option (d): bounded exact rationals (decimals are the power-of-10-denominator subset), demoting to f64 only on overflow; to be measured.","created_at":"2026-09-25T06:11:02Z"},{"id":"bd0edc40-db14-5aa1-9ffe-8d013e732958","issue_id":"improvise-l2b8","author":"Edward Langley","text":"USER 2026-09-25, verbatim: 'I think ADR 005 should implement the math operations in a separate crate'. The numeric tower (Num, arithmetic, contagion, overflow detection, conversion, exact comparison, the exact-sum accumulator, display rounding, exact parsing) becomes its own problem-agnostic workspace crate that improvise-core depends on.","created_at":"2026-09-25T06:39:37Z"}],"dependency_count":0,"dependent_count":0,"comment_count":3} {"_type":"issue","id":"improvise-wiew","title":"[ and ] should probably be in the global keymap: decide whether 'global' means every mode or every grid mode","description":"USER 2026-09-22: \"Also [/] probably should be in the global keymap.\"\n\nNote the \"probably\" -- judgement is invited, and there is a real question below.\n\nWHERE THEY ARE NOW, verified: keymap.rs:615-616 binds ']' -\u003e page-next and\n'[' -\u003e page-prev on NORMAL. global_keymap() (:392) currently holds '?', ':' and\nthe panel toggles.\n\nMECHANICALLY THIS IS SAFE AND CHEAP. Both are unmodified Char keys bound as\nBinding::Cmd, so they satisfy the_global_binds_only_unmodified_char_keys, the\nguard PR #112 added. And TEXT ENTRY IS NOT AFFECTED: the lookup order is\nexact(key,mods) -\u003e Char(NONE) -\u003e AnyChar -\u003e Any -\u003e parent, so a text-entry mode's\nAnyChar shadows the global BEFORE the fallthrough is reached. Typing '[' into a\ncategory name still types '['. That shadowing is already covered by #112's tests.\n\nNO EXACT CONFLICT EXISTS. The Help overlay pages with Right / l / n, not with\nbrackets, so nothing is shadowed by accident.\n\nTHE ACTUAL QUESTION, WHICH THE TICKET MUST ANSWER RATHER THAN ASSUME: is the\nglobal \"every mode\", or \"every mode whose subject is the grid\"? Falling through\nmeans [ and ] would change the DOCUMENT'S PAGE AXIS while the user is reading the\nHELP OVERLAY -- a real mutation, invisible because the overlay is covering the\ngrid it changed. The same question applies to TileSelect and the three panels,\nthough those are at least about the document.\n\nThree defensible answers:\n a. Global, unshadowed -- paging is always available, and the help overlay\n changing the page underneath is harmless because it is visible on exit.\n b. Global, shadowed to a no-op in Help -- costs a wart, but says plainly that\n an overlay is not a place to mutate the document.\n c. Not global; add it to the specific maps that want it (RecordsNormal already\n inherits Normal, so the real gain is the panels and TileSelect).\n\nPick one, say why in the PR body, and expect the user to have an opinion --\nthis is the kind of thing that is cheap now and annoying to reverse once muscle\nmemory forms.\n\nWHATEVER IS CHOSEN, the browser must follow: improvise-protocol/src/keymap.rs is\nthe shared table both front ends look up, so this is one change, not two -- but\ncheck resolve.rs still composes page-next/page-prev from whatever modes newly\nreach them, and that lockstep still agrees. improvise-p1ym closed the projection\ngap that used to make [ and ] inert in a browser, so they do work there now.","notes":"IMPLEMENTED in PR #123 (merge 7470b61), to the user's ruling of an INTERMEDIATE\nkeymap rather than any of the three options this ticket listed.\n\ndocument_keymap sits between global and Normal / the three panels / TileSelect.\nRecordsNormal reaches it THROUGH Normal. Help, ImportWizard and the text-entry\nmodes take global directly.\n\nTHE ARGUMENT IS NEW RATHER THAN THE GLOBAL'S AGAIN, and it is better than the\none I wrote on this ticket. I framed the question as \"is the document the subject\nright now\", which is a judgement. The agent found a STRUCTURAL line: every key\nthe global binds is a UI action, whereas page-next resolves to SetPageSelection,\nwhich is EffectKind::Model(LayoutGeometry) -- THE FIRST SHARED KEYS THAT MUTATE.\nUnder the help overlay that is an invisible change to the grid it is covering.\nSo the split is not taste; it is \"may a fallthrough key mutate the document\".\n\nThe three asks were met: the name is the guard (document_keymap, not\ncommon/shared, because nothing in the code stops a mode parenting it);\nRecordsNormal inherits ONCE, asserted on CHAIN IDENTITY (exactly four deep)\nrather than behaviour; and the layer gets its OWN Char-only guard, owed for the\nidentical fallthrough reason as the global's.\n\nTHE TEST-SHAPE LESSON: parenting RecordsNormal to the layer directly is a fault\nthe BEHAVIOURAL test does not catch -- it passes, because the key still works.\nOnly records_normal_inherits_the_document_layer_once_through_normal, which\nasserts the chain, sees it. A duplicated inheritance edge is invisible to any\ntest that only presses the key.\n\nprefix_keymaps_are_flat is about the sub-keymaps a PREFIX opens and is unaffected\nby depth. resolve.rs dispatches on command NAME, so the browser followed with no\nsecond table and no resolve.rs change was needed. lockstep.rs unchanged.\nIt did not grow: one binding moved, one layer added.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:47Z","closed_at":"2026-09-22T06:41:47Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-771e","title":"The browser tile bar says 'Pag': short_label is a TUI column-width device, not a browser one","description":"Spotted by the orchestrator in PR #120's screenshots: the browser tile bar renders\n\"_Measure Col\", \"Product Row\", \"Quarter Pag\".\n\n\"Pag\" is not a CSS clip and not a bug in #120. Axis::short_label() (improvise-core\n/src/view/axis.rs) returns THREE CHARACTERS on purpose -- repo-map gotcha 26 says\nit is \"three columns for the tile bar\", so the TUI's tile bar aligns in a\nfixed-width terminal. Note `None` is \".\" there and is deliberately NOT a\ntruncation.\n\nIn a browser there is no column-alignment constraint, so the abbreviation buys\nnothing and reads to a user as a rendering defect. Axis::plain_label() already\nreturns \"Row\"/\"Col\"/\"Page\"/\"None\".\n\nTHE FIX IS NOT TO CHANGE short_label(). It is shared with the TUI, where the\nthree-column width is load-bearing. Pick the other EXISTING method on the browser\nside. That is not a second derivation -- both are already on Axis and the decision\nstays in the shared layer; only the choice of which vocabulary a front end uses is\nlocal, which is exactly the shape design-principles section 5 blesses for\nAxisColor -\u003e ratatui Color.\n\nCHECK BEFORE CHANGING: tests/grid_frame_agreement.rs asserts the two front ends\ndraw the same grid FRAME. Confirm it does not also pin the tile bar's label\nvocabulary, or this will fail for a reason that is not a bug.","notes":"FIXED in PR #123 (merge 7470b61). The browser tile bar asks plain_label();\nshort_label() now has exactly one caller, src/ui/tile_bar.rs.\n\nTHE FIND THAT MATTERS MORE THAN THE FIX, and it corrects this ticket's own\nguidance. I told the agent to check tests/grid_frame_agreement.rs before\nchanging the label, on the theory that it might pin the tile bar's vocabulary.\nIT DOES NOT -- it does not mention the tile bar at all.\n\nBut harness/tests/drill.spec.js DID: it asserted the tile text exactly, twice,\nwith \"Quarter Pag\" AS THE EXPECTED VALUE, plus \"Quarter ·\" after a cycle. A\nPlaywright spec in a directory this ticket never names, asserting the bug as its\nexpectation. Verified fixed on the merged branch: it now expects \"Quarter Page\".\n\nSO THE RULE IS: A RENDERED STRING IS PINNED WHEREVER ANYTHING READS THE SCREEN.\nThe search is for the STRING, not for the function that produces it -- I pointed\nat the function and named the wrong file. A now-false comment in\ncontract.spec.js naming short_label was fixed in the same PR.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T06:07:11Z","created_by":"Edward Langley","updated_at":"2026-09-22T06:41:46Z","closed_at":"2026-09-22T06:41:46Z","close_reason":"Closed","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ycxi","title":"The release preflight's dist plan arm is unchecked by its check_the_checker","description":"Proposed by the improvise-gx5d agent (PR #119) and DECLINED IN THAT PR on stated\ngrounds, which is why it is a bead rather than a fix.\n\nscripts/release-preflight.sh runs `dist plan --tag=\u003ctag\u003e` as a SECOND OPINION after\nits own textual tag-vs-version check, and matches on the refusal text. That arm can\nbe rewritten so it NEVER MATCHES and scripts/release-preflight-test.sh stays green,\nbecause every input the test feeds the script has ALREADY been refused by the\ntextual check -- so control never reaches the dist arm under test.\n\nWHY IT WAS DECLINED RATHER THAN FIXED, and the reasoning is sound: the arm is\nOPTIONAL BY DESIGN. An absent dist must not read as a pass, and a locally-installed\ndist whose version differs from dist-workspace.toml's cargo-dist-version stops\nbefore planning, which the script deliberately tolerates. Pinning the arm in a test\nwould mean installing a dist matching cargo-dist-version -- the exact mismatch the\nscript exists to tolerate.\n\nSO THE VALUE HERE IS NOT COVERAGE, IT IS THAT THE SILENCE IS RECORDED. It is stated\nin context/code/tooling.md so nobody reads the check_the_checker's green as evidence\nabout that arm. This bead exists so the statement does not become the permanent\nanswer by default.\n\nWHAT WOULD ACTUALLY CLOSE IT, if anyone takes it up: feed the dist arm an input the\ntextual check ACCEPTS but dist refuses. That requires a tree whose root version\nmatches the tag while dist still objects -- e.g. a dist-workspace.toml naming a\nmember that does not exist. That is a fixture, not an installed dist, and it may be\ncheap. Worth ten minutes before accepting the decline as permanent.\n\nDO NOT close this by deleting the dist arm. It is the thing that actually refused in\nCI, and running it is worth more than any restatement of its rules; the textual\ncheck is the one that catches the failure the script was written for, but it is not\nthe authority.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:40:52Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:40:52Z","dependencies":[{"issue_id":"improvise-ycxi","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:04:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-htl6","title":"view-forward is bound in no keymap, in either front end","description":"Found by the improvise-cfly agent while adding the browser's view-back arm, and\nNOT predicted by that ticket. Verified by the orchestrator: grep of\nKeymaps::default_keymaps() finds `\u003c` bound to view-back and NOTHING bound to\nview-forward.\n\nSo the FORWARD half of the view history has never had a key, in either front end:\n\n - TUI: reachable only as `:view-forward` through the command line.\n - BROWSER: not reachable at all -- declined() refuses to enter CommandMode at\n the door (improvise-y11g is the ruling that this must change).\n\nTHIS IS A TUI GAP FIRST, which is the surprising part. The browser was the thing\nunder investigation and the browser is the second victim, not the first.\n\nBoth ends COMPOSE the command correctly since improvise-cfly / PR #117; only the\nbinding is missing. So this is one keymap line plus its consequences, not a\nfeature.\n\nWHAT IT NEEDS:\n - a key. `\u003e` is taken by drill-into-cell, so this needs a choice rather than\n the obvious symmetry -- say what you picked and why.\n - a help.rs hint, or `every_hint_key_resolves_in_its_mode` and\n `only_quit_has_no_hint` will have something to say.\n - it WILL fail resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it,\n which PR #117 added precisely so this gap cannot be closed silently. That\n test asserts the arm exists AND that no keymap binds view-forward, with\n view-back's presence as the control so it is not vacuous over a misspelling.\n Deleting the absence assertion is the correct move when adding the binding --\n coming there and saying so is the point.\n\nWHY IT SURVIVED: pressing `\u003c` twice is the only way to observe the forward stack,\nso nothing a user does by accident reaches it, and until PR #115 the stack was\nnot maintained for any client at all (gotcha 38's correction).","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui2","owner":"el-github@elangley.org","created_at":"2026-09-22T05:21:26Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:32Z","started_at":"2026-09-23T02:17:59Z","closed_at":"2026-09-23T02:36:32Z","close_reason":"PR #155. view-forward was bound in NO keymap in either front end from the day it was written -- reachable only as ':view-forward'. Bound to ',' in the shared keymap. THE KEY ARGUMENT, since the ticket asked for one: '\u003c' is view-back and '\u003e' is drill-into-cell, so the pair that LOOKS symmetric is not one -- '\u003e' moves within the grid, '\u003c' moves between views -- and binding the forward half to anything reading as '\u003e's partner deepens exactly the confusion that hid the gap. ',' is the unshifted twin of '\u003c', so both halves of the history sit on one physical key with no grid-direction meaning. Deliberately NOT a Ctrl- key: Ctrl-R is browser reload and the keymap is SHARED, so a terminal choice is one the browser must live with -- improvise-clso is already open for that class and adding a second instance while closing another is not a trade worth making. Verified by the orchestrator: ',' was unbound elsewhere, and the change is one line in keymap.rs. THE ABSENCE ASSERTION WAS INVERTED, NOT DELETED -- the_forward_step_mirrors_its_cmd_and_no_key_reaches_it became ..._and_one_key_reaches_it, keeping view-back as a control that now guards the opposite failure (a table that binds everything), plus a third assertion pinning the key so the binding cannot drift while the presence check stays happy. resolve.rs needed NO code: it has had the arm since improvise-cfly. USER-FACING AND REVERSIBLE IN ONE LINE if a different key is preferred.","labels":["browser","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-z24n","title":"Ctrl-Q should not be claimed by the browser at all: force-quit resolves Ignored, not Upstream","description":"USER RULING 2026-09-22: \"What should Ctrl-Q do in a browser tab? this shortcut\nonly really makes sense in an application context.\"\n\nSo: the browser must NOT claim it. `force-quit` should stop resolving to\nKeyOutcome::Upstream and resolve to Ignored -- no frame composed, nothing sent.\n\nWHAT IS ACTUALLY WRONG TODAY is narrower and stranger than \"a black hole\", and the\ndifference is the reason this is worth a ticket rather than a one-liner. web/app.js\n(~:599) reads:\n\n if (!e.ctrlKey \u0026\u0026 !e.altKey) e.preventDefault();\n\nwith the comment \"Ctrl/Alt combinations are left alone: they are the browser's.\"\nSo the page ALREADY declines to consume Ctrl chords. But `client.on_key_event` is\ncalled FIRST and force-quit resolves to Upstream, so the page composes and sends a\nClientFrame::Key that Host::handle answers with an empty vec (gotcha 47) -- AND the\nbrowser also acts on Ctrl-Q, which on Linux Firefox and Chrome QUITS THE BROWSER.\n\nSo the present behaviour is: send a useless frame, and let the browser quit. The\nfix aligns the resolver with the policy app.js already states rather than inventing\none -- which is why the answer is Ignored and NOT \"decline with a status message\":\na message would imply improvise had claimed the key, and it deliberately has not.\n\nCHECK BEFORE ASSUMING THE ABOVE: I read the handler and did not run it. Confirm by\ninjection that Ctrl-Q currently produces a frame, rather than trusting this note --\nthat is the whole discipline this repo runs on.\n\nDO IT IN THE SAME COMMIT: remove `force-quit` from\nthe_command_names_that_still_need_the_host_are_these, because gotcha 47 says closing\na gap has to come to that list and say so. This one closes by DECLINING rather than\nby composing, which the list should be able to express -- if it cannot, that is the\nfinding.\n\nRELATED: improvise-uln5 (help page 5 advertises keys a browser cannot resolve). If\nCtrl-Q is genuinely not a browser feature, the browser's help should not list it,\nand that is uln5's business rather than this ticket's -- but note it there.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:58Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-dsn2","title":"Browser back/forward buttons and real URLs: deferred, not foreclosed","description":"USER RULING 2026-09-22: \"I want to ignore browser history for now, without\nforeclosing the question: GitHub Pages is going to make actual client-side\nnavigation annoying.\" Clarified on the same day: \"Only actual browser history,\n\u003c/\u003e is a core feature but I don't much care about how it interacts with the\nback/forward buttons at the moment.\"\n\nSO THE SPLIT IS EXPLICIT, and the two halves must not be confused again:\n\n IN SCOPE AND SHIPPING -- the IN-APP view history. `\u003c` and `\u003e` over\n ViewState::view_back_stack, a CORE FEATURE per the user. PR #115 landed the\n mechanism (Command::ViewBack/ViewForward/ForgetView, per-client stacks,\n ForgetView pruning a deleted view from every client's history); improvise-cfly\n / PR #117 gives the browser a key that reaches it. Nothing here is deferred.\n\n DEFERRED, NOT FORECLOSED -- binding any of that to the BROWSER's own history:\n history.pushState, real URLs per document or per view, and the back/forward\n buttons doing anything at all.\n\nWHY DEFERRED, in the user's own reasoning: GitHub Pages is the deployment target\nand has NO server-side rewrite. A client-side router therefore needs either hash\nrouting or the 404.html-copies-index.html trick, and a deep link to a document or\na view is awkward under both. The cost lands on the deployment, not on the code,\nwhich is exactly the kind of cost that is cheap to defer and expensive to\ndiscover late.\n\nWHAT IT WOULD TOUCH IF TAKEN UP, recorded so the shape is not re-derived:\nweb/app.js (the only file that knows about the page), web/assemble.sh and the\npages-deploy job in .github/workflows/ci.yml. It does NOT touch the protocol:\nCommand::SwitchView, ViewBack and ViewForward already exist, so this is a\nquestion about the SHELL, not the vocabulary -- which is the main reason it is\nsafe to leave open.\n\nONE THING TO CHECK BEFORE BUILDING IT, because it decides the design: a URL that\nnames a view is a URL that names ONE CLIENT'S UI state, and gotcha 29 says the\nmodel is shared and the UI is not. A shared link that moves the recipient's view\nis fine; one that moves the SENDER's is the bug. Decide which a URL means before\nwriting a router.\n\nDO NOT close this as done when \u003c/\u003e ships. They are different features and the\ntracker should not let the second stand in for the first.","status":"blocked","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:12:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:12:04Z","labels":["browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-z24n","title":"Ctrl-Q should not be claimed by the browser at all: force-quit resolves Ignored, not Upstream","description":"USER RULING 2026-09-22: \"What should Ctrl-Q do in a browser tab? this shortcut\nonly really makes sense in an application context.\"\n\nSo: the browser must NOT claim it. `force-quit` should stop resolving to\nKeyOutcome::Upstream and resolve to Ignored -- no frame composed, nothing sent.\n\nWHAT IS ACTUALLY WRONG TODAY is narrower and stranger than \"a black hole\", and the\ndifference is the reason this is worth a ticket rather than a one-liner. web/app.js\n(~:599) reads:\n\n if (!e.ctrlKey \u0026\u0026 !e.altKey) e.preventDefault();\n\nwith the comment \"Ctrl/Alt combinations are left alone: they are the browser's.\"\nSo the page ALREADY declines to consume Ctrl chords. But `client.on_key_event` is\ncalled FIRST and force-quit resolves to Upstream, so the page composes and sends a\nClientFrame::Key that Host::handle answers with an empty vec (gotcha 47) -- AND the\nbrowser also acts on Ctrl-Q, which on Linux Firefox and Chrome QUITS THE BROWSER.\n\nSo the present behaviour is: send a useless frame, and let the browser quit. The\nfix aligns the resolver with the policy app.js already states rather than inventing\none -- which is why the answer is Ignored and NOT \"decline with a status message\":\na message would imply improvise had claimed the key, and it deliberately has not.\n\nCHECK BEFORE ASSUMING THE ABOVE: I read the handler and did not run it. Confirm by\ninjection that Ctrl-Q currently produces a frame, rather than trusting this note --\nthat is the whole discipline this repo runs on.\n\nDO IT IN THE SAME COMMIT: remove `force-quit` from\nthe_command_names_that_still_need_the_host_are_these, because gotcha 47 says closing\na gap has to come to that list and say so. This one closes by DECLINING rather than\nby composing, which the list should be able to express -- if it cannot, that is the\nfinding.\n\nRELATED: improvise-uln5 (help page 5 advertises keys a browser cannot resolve). If\nCtrl-Q is genuinely not a browser feature, the browser's help should not list it,\nand that is uln5's business rather than this ticket's -- but note it there.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T05:13:58Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:13:58Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-z24n","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dsn2","title":"Browser back/forward buttons and real URLs: deferred, not foreclosed","description":"USER RULING 2026-09-22: \"I want to ignore browser history for now, without\nforeclosing the question: GitHub Pages is going to make actual client-side\nnavigation annoying.\" Clarified on the same day: \"Only actual browser history,\n\u003c/\u003e is a core feature but I don't much care about how it interacts with the\nback/forward buttons at the moment.\"\n\nSO THE SPLIT IS EXPLICIT, and the two halves must not be confused again:\n\n IN SCOPE AND SHIPPING -- the IN-APP view history. `\u003c` and `\u003e` over\n ViewState::view_back_stack, a CORE FEATURE per the user. PR #115 landed the\n mechanism (Command::ViewBack/ViewForward/ForgetView, per-client stacks,\n ForgetView pruning a deleted view from every client's history); improvise-cfly\n / PR #117 gives the browser a key that reaches it. Nothing here is deferred.\n\n DEFERRED, NOT FORECLOSED -- binding any of that to the BROWSER's own history:\n history.pushState, real URLs per document or per view, and the back/forward\n buttons doing anything at all.\n\nWHY DEFERRED, in the user's own reasoning: GitHub Pages is the deployment target\nand has NO server-side rewrite. A client-side router therefore needs either hash\nrouting or the 404.html-copies-index.html trick, and a deep link to a document or\na view is awkward under both. The cost lands on the deployment, not on the code,\nwhich is exactly the kind of cost that is cheap to defer and expensive to\ndiscover late.\n\nWHAT IT WOULD TOUCH IF TAKEN UP, recorded so the shape is not re-derived:\nweb/app.js (the only file that knows about the page), web/assemble.sh and the\npages-deploy job in .github/workflows/ci.yml. It does NOT touch the protocol:\nCommand::SwitchView, ViewBack and ViewForward already exist, so this is a\nquestion about the SHELL, not the vocabulary -- which is the main reason it is\nsafe to leave open.\n\nONE THING TO CHECK BEFORE BUILDING IT, because it decides the design: a URL that\nnames a view is a URL that names ONE CLIENT'S UI state, and gotcha 29 says the\nmodel is shared and the UI is not. A shared link that moves the recipient's view\nis fine; one that moves the SENDER's is the bug. Decide which a URL means before\nwriting a router.\n\nDO NOT close this as done when \u003c/\u003e ships. They are different features and the\ntracker should not let the second stand in for the first.","status":"blocked","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-22T05:12:00Z","created_by":"Edward Langley","updated_at":"2026-09-22T05:12:04Z","labels":["browser"],"dependencies":[{"issue_id":"improvise-dsn2","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:38Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-swzk","title":"A failed :view corrupts the TUI's own view history","description":"Found by the improvise-h3y7 completeness audit.\n\neffect::SwitchView (ui/effect.rs:336) pushes the current view onto view_back_stack\nBEFORE calling App::switch_to_view, which is guarded (ui/app.rs:565) and writes\nactive_view only if the view exists.\n\nSo `:view NoSuchView` leaves the user where they were WITH THEIR OWN CURRENT VIEW\nPUSHED ONTO THEIR OWN BACK STACK. `\u003c` then \"goes back\" to where they already are, and\none real history frame is buried under it.\n\nTHE WIRE PATH IS ALREADY CORRECT and this is not a regression of it: reduce_view's\n`switched` has no guard (the client holds no Workbook, so it cannot check), but\nSessions::apply_switch detects the refusal and relays the exact inverse\n([ViewBack, ForgetView], improvise-6c6q). Only the HOST'S OWN KEY PATH is unguarded.\nTUI-only, small, real.\n\nFIX: move the push after the switch, or make it conditional on the switch having\nhappened. Prefer whichever makes the ordering unrepresentable-otherwise rather than\nconventional.\n\nDEMONSTRATE WITH A FAILING TEST FIRST (AGENTS.md): switch to a real view, then to a\nnonexistent one, then `\u003c`, and assert you land on the real one. Note the test must use\na real second view -- a bystander sitting on the view it would land on anyway cannot\nsee this, which is the vacuity review demonstrated twice on PR #48.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui2","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:36:32Z","started_at":"2026-09-23T02:03:03Z","closed_at":"2026-09-23T02:36:32Z","close_reason":"PR #154. SwitchView::apply pushed the leaving frame onto view_back_stack BEFORE calling the guarded App::switch_to_view, under the predicate previous != self.0 -- which answers 'did the user ask for a different name' where the history means 'did this client go somewhere'. They differ on exactly one input: a name the document has not got. Fixed by observation rather than prediction (clone previous, switch, push only if active_view actually differs afterwards), which SUBSUMES the same-view case so the special case is gone rather than joined by a second one. The agent explicitly rejected re-testing workbook.views.contains_key as a second copy of switch_to_view's own condition. THE FIXTURE WAS MEASURED, NOT ASSUMED: the vacuous variant (refuse from Default, then '\u003c') PASSES AGAINST THE UNFIXED CODE, because the phantom frame it pops names Default and Default is where you already are. Rival 'if false' fails 11 tests, which is why the test asserts one frame SURVIVES and not only where the index lands. Sibling hazard filed as improvise-8nd9.","labels":["tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4n4i","title":"effect::HelpPageSet is unclamped where its wire mirror clamps","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects).\n\neffect::HelpPageSet(n) (ui/effect.rs:1174) writes `help_page = self.0` with NO bound.\nreduce_view's arm clamps: `HelpPage::Set(n) =\u003e (*n).min(HELP_PAGE_COUNT - 1)`.\n\nreduce.rs's own comment above that arm says \"these mirror HelpPageNext/HelpPagePrev,\nwhich saturate. The two used to disagree\" -- Next/Prev were fixed and Set WAS LEFT\nBEHIND, so the comment is now false of the arm it sits above. A stale correction: the\nsame failure mode as gotcha 38's prose, and the reason this is filed rather than\nquietly patched.\n\nLATENT, NOT LIVE, and the ticket should say so rather than overstate it:\neffect::help_page_set has exactly one call site (cmd/effect_cmds.rs:404) passing the\nliteral 0, and no client composes HelpPage::Set (resolve.rs has only Next/Prev arms).\nSo it is an asymmetry waiting for its first real caller.\n\nFIX: clamp the effect, and correct the comment in the same commit -- the comment is\nthe thing that made this survive.\n\nTHE REGRESSION GUARD: a test that the two agree for n \u003e HELP_PAGE_COUNT. Nothing tests\nthis in EITHER direction today, which is why a one-sided fix would look complete.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:54Z","labels":["protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-t3oh","title":"The cache-parity guard's whole-cache escape hatch proves consumption, not coverage","description":"Stated as a residual by the agent that WROTE the guard, in PR #113's evidence,\nrather than found later by someone else.\n\n`web/tests/render_surface.test.js` gained a second check one level up: every\n`pub` field of every widget cache reaches a front end, or is named as debt. It\nhas an escape hatch for the case where a whole cache is handed to a shared\nderivation, e.g. `flatten_cat_tree(\u0026state.categories, ..)` -- in which case\nevery field of that cache counts as reached.\n\nTHE HOLE: a second field added to `CategoryTreeCache` and ignored by\n`flatten_cat_tree` passes. The hand-off proves the cache is CONSUMED, never that\nevery field of it is.\n\nClosing it needs the CONSUMER checked rather than the call site -- i.e. asking\nwhat `flatten_cat_tree` actually reads, not that it was handed the struct.\n\nWHY THIS BEAD EXISTS RATHER THAN A TODO: the same guard's two OTHER escape\nhatches were both vacuous on first writing and both were found only by\ninjection -- `\u0026state.grid` is a substring of `\u0026state.grid.row_frame`, so every\nGridCache field read as covered (vacuous for precisely the cache p7lh was\nabout), and rustfmt splits `state.grid.totals` over three lines so a substring\nsearch calls a read field unread. Two of three hatches were wrong. The third\nbeing merely incomplete rather than wrong is not a reason to trust it.\n\nSee doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:50Z","labels":["browser","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ozxr","title":"The browser's page bar is display-only; clicking an entry cannot cycle it","description":"PR #113 (improvise-p7lh) made the page bar VISIBLE. It is display-only.\n\nIn the TUI, `[` and `]` cycle the page selection and the page bar is where a\npaged category is both shown and navigated. In the browser the bar now paints,\nbut clicking an entry does nothing -- cycling needs a `click_page` on the\nwasm-bindgen surface (`improvise-wasm-client/src/lib.rs`), which was outside\nthat agent's ground.\n\nWORTH KNOWING BEFORE SCOPING IT: `[` and `]` ALREADY WORKED in the browser\nbefore #113. The feature was never missing -- the FEEDBACK was. That is a worse\nfailure than a dead key, because the user sees the document change and cannot\nattribute it. So this bead is the mouse affordance for something the keyboard\nalready does, not new capability.\n\nPattern to follow: the other five surfaces got delegated handlers in\nimprovise-b8ot, with operands carried on the node as data attributes and read\nback by a pure `cellAction`/`indexAction` in `web/shell.js`. A page entry should\ndo the same -- `data-index` on the entry, a pure `pageAction`, one handler on\nthe `#pages` root. Do NOT derive the index from DOM position.\n\nNote gotcha 27: Page is the NAVIGABLE axis and Filter is not, and `PageAxis::cycle`\nis where that question is already answered -- ask it rather than respelling it.\n\nRelated: improvise-z336 (a page selection is never cleared, so cycling a\ncategory's axis hides a filter and cycling back restores it) is the other half\nof the page-axis story and is core-side, not browser-side.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:49Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-nwgk","title":"Model::remove_item returns unit, so a wire RemoveItem naming nothing still reports success","description":"Left open by improvise-g3no, deliberately, with the reasoning in\ncontext/code/core.md section 9.\n\ndoctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)\ngeneralises to: a mutation whose failure is reportable must RETURN whether it\nacted, because a unit-returning mutation makes the lie unavoidable rather than\naccidental. g3no removed the REACHABLE lie by restoring the formula sweep to\nModel::remove_item -- from either panel the item name comes off the tree, so it\nexists and the delete now really happens. What is left is narrow: a wire\nModelCommand::RemoveItem naming an item the document does not have is still\nanswered with an unconditional past-tense \"Deleted item ...\".\n\nWhy it was not done in that PR: closing it means threading an answer through\nWorkbook::remove_item and effect::RemoveItem, and ui/effect.rs was another\nagent's ground in that wave. It is a two-file change, not a design question.\n\nThe browser half is separately blocked and is the more interesting part:\nSetStatus is ViewOnly and never crosses the wire, so a host-side refusal cannot\nreach a client at all, and nothing in the projection stream says which _Measure\nrows are derived (FormulaCache is Vec\u003cString\u003e of Formula::raw, and recovering a\ntarget from that would be a second home for the unquoting gotcha 34 says has\nexactly one). A browser that is to tell the truth about a refused delete needs\neither a ServerFrame::Rejected arm or an enriched projection.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:40Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:40Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-25bf","title":"The category panel's Space cycles the axis silently while the tile route announces it","description":"MEASURED at 523d571: cycle-axis-at-cursor from the category panel emits no\nSetStatus at all, where the tile-bar route for the same operation emits\n\"Product -\u003e None\".\n\nFour Space presses moved Product's axis through None/Row/Col/Page with #status\nfrozen the whole time on a stale \"Filter: Product = Espresso\" (see\nimprovise-z336 for the staleness itself).\n\nSo there are two derivations of \"cycle this category's axis\" and only one of\nthem tells the user. design-principles section 5 is about exactly this: a\nderivation done twice will diverge, and the divergence is invisible until\nsomeone compares them side by side.\n\nWorth noting Space IS advertised in the CATEGORIES hint as \"Space:cycle-axis\",\nso this is not a black hole -- the key works. It is the feedback that is missing,\nwhich makes it a smaller problem than improvise-koh6 and a different one.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:25Z","labels":["browser","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4n4i","title":"effect::HelpPageSet is unclamped where its wire mirror clamps","description":"Found by the improvise-h3y7 completeness audit (direction B: vocabulary -\u003e effects).\n\neffect::HelpPageSet(n) (ui/effect.rs:1174) writes `help_page = self.0` with NO bound.\nreduce_view's arm clamps: `HelpPage::Set(n) =\u003e (*n).min(HELP_PAGE_COUNT - 1)`.\n\nreduce.rs's own comment above that arm says \"these mirror HelpPageNext/HelpPagePrev,\nwhich saturate. The two used to disagree\" -- Next/Prev were fixed and Set WAS LEFT\nBEHIND, so the comment is now false of the arm it sits above. A stale correction: the\nsame failure mode as gotcha 38's prose, and the reason this is filed rather than\nquietly patched.\n\nLATENT, NOT LIVE, and the ticket should say so rather than overstate it:\neffect::help_page_set has exactly one call site (cmd/effect_cmds.rs:404) passing the\nliteral 0, and no client composes HelpPage::Set (resolve.rs has only Next/Prev arms).\nSo it is an asymmetry waiting for its first real caller.\n\nFIX: clamp the effect, and correct the comment in the same commit -- the comment is\nthe thing that made this survive.\n\nTHE REGRESSION GUARD: a test that the two agree for n \u003e HELP_PAGE_COUNT. Nothing tests\nthis in EITHER direction today, which is why a one-sided fix would look complete.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T04:47:54Z","created_by":"Edward Langley","updated_at":"2026-09-22T04:47:54Z","labels":["protocol"],"dependencies":[{"issue_id":"improvise-4n4i","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:00Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-t3oh","title":"The cache-parity guard's whole-cache escape hatch proves consumption, not coverage","description":"Stated as a residual by the agent that WROTE the guard, in PR #113's evidence,\nrather than found later by someone else.\n\n`web/tests/render_surface.test.js` gained a second check one level up: every\n`pub` field of every widget cache reaches a front end, or is named as debt. It\nhas an escape hatch for the case where a whole cache is handed to a shared\nderivation, e.g. `flatten_cat_tree(\u0026state.categories, ..)` -- in which case\nevery field of that cache counts as reached.\n\nTHE HOLE: a second field added to `CategoryTreeCache` and ignored by\n`flatten_cat_tree` passes. The hand-off proves the cache is CONSUMED, never that\nevery field of it is.\n\nClosing it needs the CONSUMER checked rather than the call site -- i.e. asking\nwhat `flatten_cat_tree` actually reads, not that it was handed the struct.\n\nWHY THIS BEAD EXISTS RATHER THAN A TODO: the same guard's two OTHER escape\nhatches were both vacuous on first writing and both were found only by\ninjection -- `\u0026state.grid` is a substring of `\u0026state.grid.row_frame`, so every\nGridCache field read as covered (vacuous for precisely the cache p7lh was\nabout), and rustfmt splits `state.grid.totals` over three lines so a substring\nsearch calls a read field unread. Two of three hatches were wrong. The third\nbeing merely incomplete rather than wrong is not a reason to trust it.\n\nSee doctrine(an_escape_hatch_is_where_a_new_check_goes_vacuous).\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:50Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:50Z","labels":["browser","testing"],"dependencies":[{"issue_id":"improvise-t3oh","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ozxr","title":"The browser's page bar is display-only; clicking an entry cannot cycle it","description":"PR #113 (improvise-p7lh) made the page bar VISIBLE. It is display-only.\n\nIn the TUI, `[` and `]` cycle the page selection and the page bar is where a\npaged category is both shown and navigated. In the browser the bar now paints,\nbut clicking an entry does nothing -- cycling needs a `click_page` on the\nwasm-bindgen surface (`improvise-wasm-client/src/lib.rs`), which was outside\nthat agent's ground.\n\nWORTH KNOWING BEFORE SCOPING IT: `[` and `]` ALREADY WORKED in the browser\nbefore #113. The feature was never missing -- the FEEDBACK was. That is a worse\nfailure than a dead key, because the user sees the document change and cannot\nattribute it. So this bead is the mouse affordance for something the keyboard\nalready does, not new capability.\n\nPattern to follow: the other five surfaces got delegated handlers in\nimprovise-b8ot, with operands carried on the node as data attributes and read\nback by a pure `cellAction`/`indexAction` in `web/shell.js`. A page entry should\ndo the same -- `data-index` on the entry, a pure `pageAction`, one handler on\nthe `#pages` root. Do NOT derive the index from DOM position.\n\nNote gotcha 27: Page is the NAVIGABLE axis and Filter is not, and `PageAxis::cycle`\nis where that question is already answered -- ask it rather than respelling it.\n\nRelated: improvise-z336 (a page selection is never cleared, so cycling a\ncategory's axis hides a filter and cycling back restores it) is the other half\nof the page-axis story and is core-side, not browser-side.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:28:49Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:28:49Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-ozxr","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-nwgk","title":"Model::remove_item returns unit, so a wire RemoveItem naming nothing still reports success","description":"Left open by improvise-g3no, deliberately, with the reasoning in\ncontext/code/core.md section 9.\n\ndoctrine(a_status_line_composed_beside_a_mutation_lies_when_the_mutation_no_ops)\ngeneralises to: a mutation whose failure is reportable must RETURN whether it\nacted, because a unit-returning mutation makes the lie unavoidable rather than\naccidental. g3no removed the REACHABLE lie by restoring the formula sweep to\nModel::remove_item -- from either panel the item name comes off the tree, so it\nexists and the delete now really happens. What is left is narrow: a wire\nModelCommand::RemoveItem naming an item the document does not have is still\nanswered with an unconditional past-tense \"Deleted item ...\".\n\nWhy it was not done in that PR: closing it means threading an answer through\nWorkbook::remove_item and effect::RemoveItem, and ui/effect.rs was another\nagent's ground in that wave. It is a two-file change, not a design question.\n\nThe browser half is separately blocked and is the more interesting part:\nSetStatus is ViewOnly and never crosses the wire, so a host-side refusal cannot\nreach a client at all, and nothing in the projection stream says which _Measure\nrows are derived (FormulaCache is Vec\u003cString\u003e of Formula::raw, and recovering a\ntarget from that would be a second home for the unquoting gotcha 34 says has\nexactly one). A browser that is to tell the truth about a refused delete needs\neither a ServerFrame::Rejected arm or an enriched projection.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T02:27:40Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:27:40Z","dependencies":[{"issue_id":"improvise-nwgk","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:19Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-25bf","title":"The category panel's Space cycles the axis silently while the tile route announces it","description":"MEASURED at 523d571: cycle-axis-at-cursor from the category panel emits no\nSetStatus at all, where the tile-bar route for the same operation emits\n\"Product -\u003e None\".\n\nFour Space presses moved Product's axis through None/Row/Col/Page with #status\nfrozen the whole time on a stale \"Filter: Product = Espresso\" (see\nimprovise-z336 for the staleness itself).\n\nSo there are two derivations of \"cycle this category's axis\" and only one of\nthem tells the user. design-principles section 5 is about exactly this: a\nderivation done twice will diverge, and the divergence is invisible until\nsomeone compares them side by side.\n\nWorth noting Space IS advertised in the CATEGORIES hint as \"Space:cycle-axis\",\nso this is not a black hole -- the key works. It is the feedback that is missing,\nwhich makes it a smaller problem than improvise-koh6 and a different one.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-22T00:22:25Z","created_by":"Edward Langley","updated_at":"2026-09-22T00:22:25Z","labels":["browser","ux"],"dependencies":[{"issue_id":"improvise-25bf","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ipqu","title":"The FitOutcome::warnings order stated in two places is checked in none","description":"Found by the adversarial reviewer of PR #111 at head 20f04c83.\n\ncrates/improvise-gp/src/fit.rs:317-318\n\n data.warnings.splice(0..0, table.warnings);\n data.warnings.splice(0..0, notices);\n\nyields notices -\u003e table -\u003e preprocess. BOTH the FitOutcome::warnings doc comment\n(\"the budget's notices, then the feature table's, then preprocess's\") AND\ngp.md section 14 assert that order, and FitOutcome's status line joins them\nverdict-first, so the order is USER-FACING.\n\nMEASURED: swapping the two statements passes all 73 improvise-gp tests.\n\nThe sharp part: PR #111's third commit (20f04c8) is a DOCS-ONLY commit whose\nentire subject is this ordering. So the repository now states the rule in two\nplaces and checks it in none.\n\nThis is the exact shape gotcha 43 records and closed -- \"held up only by the\norder of two statements\", fixed there by\na_published_basis_follows_the_grid_updates_it_describes, which was proved by\ninjecting the swap. Same repair applies here.\n\nWHAT IS NEEDED: one test asserting a notice precedes a table warning when BOTH\nare present. No current fixture produces both, which is why nothing caught it --\na budget that drops a column and a feature table that also warns. That fixture is\nthe work.\n\nSee also doctrine(fold_tests_must_test_order_not_the_axiom): asserting the\nconcatenation contains both is vacuous; the assertion has to be about ORDER.\n\nNOT a correctness bug, not a regression, and it touches neither improvise-pv3g's\nnor improvise-e8no's acceptance criteria -- hence P3 and not a merge blocker.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T23:29:51Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:32Z","started_at":"2026-09-23T01:25:50Z","closed_at":"2026-09-23T02:44:32Z","close_reason":"PR #146. Pins the FitOutcome::warnings order that the doc comment and gp.md both state. THE FIXTURE WAS THE WHOLE WORK -- no existing fixture produced more than one tier. all_three_warning_tiers_at_once adds a _Measure item with no cell anywhere (preprocess warns) and a Cost cell at an unused coordinate (feature_table skips). Adversarial review PRINTED the actual warning vector rather than trusting the at() helper: exactly three, 1:1 with the needles, no collision. Both rivals confirmed failing only this test. The extend-instead-of-splice rival is what earns the third tier -- the splice swap alone never moves preprocess's warning, so a two-tier fixture would have pinned less than claimed.","labels":["gp","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nzeu","title":"A deserialized TargetModel can still carry a non-finite affine map","description":"Left open deliberately by PR #111 (improvise-pv3g), which made a non-finite\naffine map unrepresentable at the point it is DERIVED but not at the point it is\nDESERIALIZED.\n\n`TargetModel::Regress { mean: f32, std: f32 }` in `crates/gp-tree/src/data.rs`\nstill carries a raw pair. So a hand-edited or corrupted JSON model can\nreintroduce a non-finite map on `fit_csv --predict`, which is exactly the\npoisoned state improvise-pv3g made unconstructible everywhere else.\n\nWhat pv3g DID close: `fit_csv --save` can no longer WRITE one, because\n`standardize` refuses before a model is produced. So the hole is narrower than\nit was and is inbound-only.\n\nTHE FIX SHAPE, and it matters which one: `#[serde(try_from = \"...\")]` on\n`Scaling`, so deserialization goes through the same one constructor that already\nrefuses a non-finite mean or std. NOT a check at the use site -- a use-site check\nis the validated form of an invariant that pv3g just spent its whole change\nmaking unrepresentable, and adding one back would leave two answers to \"is this\nmap usable\" with nothing keeping them in agreement.\n\nNote this reaches `Scaling`'s constructor by a path that IS meaningful for its\notherwise-unreachable `mean` check: `doctrine(an_unreachable_half_of_a_guard_must_say_so)`\nrecords that no `standardize` path can produce a non-finite mean, so the\nconstructor's mean arm is a contract of the type only. A deserializer is\nprecisely the caller that makes that contract load-bearing, so closing this bead\nalso gives that half a reachable path and a test.\n\nReachability today: requires a hand-edited or corrupted model file. Low, which is\nwhy it was not widened into pv3g's scope.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T23:05:32Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:33Z","started_at":"2026-09-23T01:30:35Z","closed_at":"2026-09-23T02:44:33Z","close_reason":"PR #147. A deserialized Scaling goes through its one constructor, via serde try_from/into plus flatten on Regress. THE COMPATIBILITY CLAIM WAS MEASURED, NOT TRUSTED: adversarial review checked out the base commit, serialized an equivalent Model and compared -- BYTE-IDENTICAL, with flatten placing the two numbers where they were. scoring_known_answers.rs untouched, 6 bit-pattern tests pass. The 1e39 -\u003e f64 -\u003e as f32 -\u003e inf path is real (serde_json will not write inf, but 1e39 is ordinary JSON a hand editor types). A P3 found by review was fixed in-PR: the refusal format string was broken across source lines with no backslash continuation, baking EIGHTEEN SPACES into a user-facing message -- visible only by PRINTING it. NOTE FOR THE NEXT READER: the only thing protecting the saved format is one assertion inside a test named about FINITENESS; a_saved_model_round_trips_with_its_seed passes happily with flatten dropped, because it round-trips through itself.","labels":["gp"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7k7v","title":"check-evidence cannot see a misfiled or body-severed evidence entry","description":"Found by a wave agent reviewing its own diff, NOT by the checker.\n\nscripts/check-comment-runs.py --check-evidence validates that every\ntest-name-shaped heading in context/test-evidence/ names a test that RUNS, and\nthat no test is named twice. It never asks whether the entry sits in the FILE it\nbelongs to, or whether a heading still has the body that was written for it.\n\nObserved: moving two new entries into protocol.md silently dragged the prune\nsection's fault table out of core.md along with them, and --check-evidence\nSTAYED GREEN. A misfiled or body-severed entry is invisible to it. Caught only\nby reading the diff -- which design-principles section 7 already records as\nhaving failed once, in the set_panel_open_and_cursor case.\n\nA second instance in the same session: wide_cell_clamps_without_overflow's\nevidence had been filed in core.md by hand, while improvise-yae4 touches\nviewport.rs and render.rs and does not touch improvise-core at all.\n\nSo the blind spot is structural rather than a slip, and it has two shapes:\n - an entry in the wrong crate's file\n - a heading whose body has been severed from it\n\nWHAT A FIX WOULD NEED TO DECIDE FIRST, because it is not obvious: there is no\nmechanical mapping from a test to the evidence file it belongs in. cargo's\n--list gives a module path, and the file naming is by AREA (core, protocol,\nclients, tooling, command, gp, formula-and-io, tui), which is a human judgement\nin the general case. A checker that guessed would produce false failures, which\nis worse than the current silence.\n\nA cheaper partial that avoids that problem entirely: assert every heading has a\nNON-EMPTY body before the next heading. That catches the severed-body shape\nwithout needing to know where an entry belongs, and it is the shape that lost\nreal content here.\n\nSee context/code/tooling.md for why the names come from cargo rather than from\nthe source (improvise-rm4s), which is the reasoning any change here must respect.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:55Z","labels":["testing","tooling"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9xsp","title":"The grid frame's prune composition is asserted at both ends and not across the seam","description":"PR #106 closed the narrower half: AxisFrame::group_of's GroupEnd rule now has\ntwo tests in improvise-protocol's render.rs, over HAND-BUILT frames. Injecting\n`FrameEntry::GroupEnd =\u003e None` fails both. Before that, the same injection left\nall 133 improvise-protocol lib tests passing.\n\nWHAT IS STILL NOT PINNED, and it is the seam rather than the contract: that\n`prune_axis_items -\u003e axis_frame -\u003e group_of` actually PRODUCES those frames. The\ntwo new tests assert group_of behaves correctly on frames someone hand-built;\nnothing asserts the real pipeline emits frames of that shape from a pruned\nlayout. So the property reaches the protocol by construction through project.rs\nand is asserted only at each end.\n\nThat is the shape gotcha 44 and design-principles section 5 exist to prevent --\na derivation living in two places with only one of them asserted.\n\nTHE FIXTURE, measured by hand and passing today (frame dump is in PR #106's\nbody), so this is a test to land rather than a bug to find:\n\n Month: Jan[Q1], Feb[Q1], Mar[Q2], Sep with Mar unfunded, prune_empty on\n assert grid_of(\u0026app, vp).row_frame.group_of(2) == None\n\n plus the collapsed fixture asserting the frame carries\n Group(FrameGroup { name: \"Q1\", collapsed: true })\n\nThe collapsed assertion matters on its own: a header arriving WITHOUT the flag\nis present and inert -- the user sees a group they cannot expand, which is\nimprovise-lhi4 one layer out.\n\nLives in improvise-command/src/ui/project.rs, which was outside the wave agent's\nground; that is the only reason it was not landed with #106.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:53Z","labels":["protocol","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7k7v","title":"check-evidence cannot see a misfiled or body-severed evidence entry","description":"Found by a wave agent reviewing its own diff, NOT by the checker.\n\nscripts/check-comment-runs.py --check-evidence validates that every\ntest-name-shaped heading in context/test-evidence/ names a test that RUNS, and\nthat no test is named twice. It never asks whether the entry sits in the FILE it\nbelongs to, or whether a heading still has the body that was written for it.\n\nObserved: moving two new entries into protocol.md silently dragged the prune\nsection's fault table out of core.md along with them, and --check-evidence\nSTAYED GREEN. A misfiled or body-severed entry is invisible to it. Caught only\nby reading the diff -- which design-principles section 7 already records as\nhaving failed once, in the set_panel_open_and_cursor case.\n\nA second instance in the same session: wide_cell_clamps_without_overflow's\nevidence had been filed in core.md by hand, while improvise-yae4 touches\nviewport.rs and render.rs and does not touch improvise-core at all.\n\nSo the blind spot is structural rather than a slip, and it has two shapes:\n - an entry in the wrong crate's file\n - a heading whose body has been severed from it\n\nWHAT A FIX WOULD NEED TO DECIDE FIRST, because it is not obvious: there is no\nmechanical mapping from a test to the evidence file it belongs in. cargo's\n--list gives a module path, and the file naming is by AREA (core, protocol,\nclients, tooling, command, gp, formula-and-io, tui), which is a human judgement\nin the general case. A checker that guessed would produce false failures, which\nis worse than the current silence.\n\nA cheaper partial that avoids that problem entirely: assert every heading has a\nNON-EMPTY body before the next heading. That catches the severed-body shape\nwithout needing to know where an entry belongs, and it is the shape that lost\nreal content here.\n\nSee context/code/tooling.md for why the names come from cargo rather than from\nthe source (improvise-rm4s), which is the reasoning any change here must respect.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:55Z","labels":["testing","tooling"],"dependencies":[{"issue_id":"improvise-7k7v","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9xsp","title":"The grid frame's prune composition is asserted at both ends and not across the seam","description":"PR #106 closed the narrower half: AxisFrame::group_of's GroupEnd rule now has\ntwo tests in improvise-protocol's render.rs, over HAND-BUILT frames. Injecting\n`FrameEntry::GroupEnd =\u003e None` fails both. Before that, the same injection left\nall 133 improvise-protocol lib tests passing.\n\nWHAT IS STILL NOT PINNED, and it is the seam rather than the contract: that\n`prune_axis_items -\u003e axis_frame -\u003e group_of` actually PRODUCES those frames. The\ntwo new tests assert group_of behaves correctly on frames someone hand-built;\nnothing asserts the real pipeline emits frames of that shape from a pruned\nlayout. So the property reaches the protocol by construction through project.rs\nand is asserted only at each end.\n\nThat is the shape gotcha 44 and design-principles section 5 exist to prevent --\na derivation living in two places with only one of them asserted.\n\nTHE FIXTURE, measured by hand and passing today (frame dump is in PR #106's\nbody), so this is a test to land rather than a bug to find:\n\n Month: Jan[Q1], Feb[Q1], Mar[Q2], Sep with Mar unfunded, prune_empty on\n assert grid_of(\u0026app, vp).row_frame.group_of(2) == None\n\n plus the collapsed fixture asserting the frame carries\n Group(FrameGroup { name: \"Q1\", collapsed: true })\n\nThe collapsed assertion matters on its own: a header arriving WITHOUT the flag\nis present and inert -- the user sees a group they cannot expand, which is\nimprovise-lhi4 one layer out.\n\nLives in improvise-command/src/ui/project.rs, which was outside the wave agent's\nground; that is the only reason it was not landed with #106.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T22:47:53Z","created_by":"Edward Langley","updated_at":"2026-09-21T22:47:53Z","labels":["protocol","testing"],"dependencies":[{"issue_id":"improvise-9xsp","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yss5","title":"extract_date_component silently drops a row whose date does not match the detected format","description":"Noticed while fixing improvise-jcfs, NOT reproduced -- so the first job is a\nfailing test, per AGENTS.md.\n\nextract_date_component silently returns None for a value that does not parse\nunder the detected format. The consequence: a row with one odd date vanishes\nfrom the derived _Year/_Month category with no warning at all.\n\nThis is the same shape as the three defects improvise-2cw9/jc95/5hfh closed --\na value the code cannot interpret becomes an absence rather than a report. The\nimport path now has warning machinery (csv_parser and wizard both emit), and the\nhouse wording is lowercase with single-quoted names and counts, e.g.\n`column 'E' missing from 2 row(s)`.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-core","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:44Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:44:33Z","started_at":"2026-09-23T01:39:55Z","closed_at":"2026-09-23T02:44:33Z","close_reason":"PR #150. A date not matching the detected format is reported rather than dropped in silence. The ticket said NOT REPRODUCED; it is real but NARROWER than it reads -- detect_date_format accepts a format only when EVERY distinct non-empty sample parses and extract_date_component makes the identical call, so through the shipped CLI and TUI wizard it is unreachable; the door is ImportPipeline's pub proposals, and that reachability claim is GUARDED rather than left as prose. The rival worth naming: warning UNCONDITIONALLY passed all 250 improvise-io tests, because no existing test enabled date extraction AND asserted silence -- that silence guard is part of the PR. A ChatGPT Codex review found the tally was incremented while walking a record's categories BEFORE the record was known to survive the walk, making the count order-dependent; fixed, and the regression asserts the fixture's category order explicitly. Adversarial review confirmed all four rivals AND verified the order assertion non-vacuous by flipping the walk order for real -- its first attempt was a no-op, because serde_json::Map is a BTreeMap so first-seen order is alphabetical.","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-cgqt","title":"Import collision policy should be a wizard step, not a fixed default","description":"improvise-2cw9 decided that two source rows landing on one coordinate tuple are\nSUMMED and reported. That decision is recorded on the bead and is right as a\ndefault -- it is what a pivot table does and what measure_agg defaults to.\n\nIt is WRONG for a measure the user later sets to Avg: the value is already\npre-summed, so the average is taken over the wrong denominator. The warning is\nwhat currently keeps that from being invisible.\n\nThe user is the only one who knows which they want. The shape is a wizard step\nthat asks, on the field where the collision was detected -- not a change to the\ndefault, and not a silent guess from the field's inferred kind.\n\nSearch `duplicate`, `collision`, `aggregate` before filing anything adjacent.\n","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:43Z","labels":["import","ux"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1uqj","title":"Long CSV rows lose their extra fields — now warned, still dropped","description":"improvise-2cw9's fix made a long CSV row REPORTED rather than silent -- a row\ncarrying more fields than the header now warns with counts and the widest row.\nThe extra values are still DROPPED.\n\nPreserving them means inventing column names, which changes the shape of the\nimported model. That is a wizard decision, not a parser one, which is why the\nimport agent stopped at \"no longer silent\" and flagged it.\n\nRelated and CLOSED: improvise-k8i covered the SHORT direction (a row with fewer\nfields than the header). Its own fix did not reach this half, because the bead\nreproduced only the short case. Read it before starting.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["import"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-is8c","title":"Move Basis from Command::Model to ClientFrame::Commands so one-basis-per-frame is not a convention","description":"Split out of improvise-yqpd, which delivered ADR-001 clause 4 but could not do\nthis half: it is a wire change in improvise-protocol/src/frame.rs.\n\nClause 1 says the FRAME states one basis. Today the Basis still sits inside\nCommand::Model, and CheckedBatch simply uses the one the first basis-carrying\ncommand stated, ignoring any later command's field. That works, and it is a\nCONVENTION rather than something the types enforce.\n\nMoving Basis from Command::Model to ClientFrame::Commands makes one-basis-per-\nframe unrepresentable-otherwise instead of conventional -- the same move the\nstruct variant made for \"the basis cannot be forgotten\" (gotcha 43).\n\nNote what this does NOT change, from gotcha 43: only two of the three tiers\nhoist. document_generation and layout_version describe the document and are\nanswered once per frame; the RECORD tier must still be answered per command,\nbecause which records are at stake is a property of the command (records_named).\nA first version that hoisted all three was a silent lost update.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["adr-001","protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1uqj","title":"Long CSV rows lose their extra fields — now warned, still dropped","description":"improvise-2cw9's fix made a long CSV row REPORTED rather than silent -- a row\ncarrying more fields than the header now warns with counts and the widest row.\nThe extra values are still DROPPED.\n\nPreserving them means inventing column names, which changes the shape of the\nimported model. That is a wizard decision, not a parser one, which is why the\nimport agent stopped at \"no longer silent\" and flagged it.\n\nRelated and CLOSED: improvise-k8i covered the SHORT direction (a row with fewer\nfields than the header). Its own fix did not reach this half, because the bead\nreproduced only the short case. Read it before starting.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["import"],"dependencies":[{"issue_id":"improvise-1uqj","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:26Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-is8c","title":"Move Basis from Command::Model to ClientFrame::Commands so one-basis-per-frame is not a convention","description":"Split out of improvise-yqpd, which delivered ADR-001 clause 4 but could not do\nthis half: it is a wire change in improvise-protocol/src/frame.rs.\n\nClause 1 says the FRAME states one basis. Today the Basis still sits inside\nCommand::Model, and CheckedBatch simply uses the one the first basis-carrying\ncommand stated, ignoring any later command's field. That works, and it is a\nCONVENTION rather than something the types enforce.\n\nMoving Basis from Command::Model to ClientFrame::Commands makes one-basis-per-\nframe unrepresentable-otherwise instead of conventional -- the same move the\nstruct variant made for \"the basis cannot be forgotten\" (gotcha 43).\n\nNote what this does NOT change, from gotcha 43: only two of the three tiers\nhoist. document_generation and layout_version describe the document and are\nanswered once per frame; the RECORD tier must still be answered per command,\nbecause which records are at stake is a property of the command (records_named).\nA first version that hoisted all three was a silent lost update.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T21:59:42Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:59:42Z","labels":["adr-001","protocol"],"dependencies":[{"issue_id":"improvise-is8c","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-vep8","title":"Connection hang has no watchdog coverage (ready never resolves)","description":"## Summary\n\nHandshake watchdog in `web/app.js` arms **after** `await transport.ready`. If ready never resolves (connection hang), watchdog never arms — UI stuck on \"connecting...\" forever.\n\n## Steps to Reproduce\n\n1. Mock WebSocket that never fires `open`.\n2. Page shows connecting forever; no timeout.\n\n## Acceptance Criteria\n\n- Connection attempt has a timeout/watchdog that arms before or independently of ready; hang surfaces a loud failure.\n\n## Notes\n\nDistinct from improvise-9cma (invalid `?doc=` blank UI / generic watchdog text after peer silence).\n\n## Location\n\n`web/app.js` ~L489-494\n\n## PR / Attribution\n\nPR #100 P3 F25. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:08Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:56Z","closed_at":"2026-09-21T21:58:56Z","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 watchdog is armed before transport.ready, plus a new spec that hangs the socket forever.","external_ref":"gh-pr-100-P3-hang","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-vep8","depends_on_id":"improvise-1wft","type":"blocks","created_at":"2026-09-21T21:31:52Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vep8","depends_on_id":"improvise-9cma","type":"relates-to","created_at":"2026-09-21T20:49:18Z","created_by":"external-grok-bot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-nvq4","title":"Hidden/collapsed items with slash in name corrupt on persistence round-trip","description":"## Summary\n\n`hidden:` persistence line format is `hidden: Cat/Item`. If Item contains `/`, the parser splits wrongly; hidden state is lost or corrupted on round-trip. A test allegedly documents this.\n\n## Steps to Reproduce\n\n1. Create item named `A/B`, hide it, save, reload.\n2. Hidden state lost or corrupted.\n\n## Acceptance Criteria\n\n- Round-trip preserves hidden/collapsed for names containing `/` (escape or alternate encoding).\n\n## Location\n\n`crates/improvise-io/src/persistence/parse.rs` (hidden: line)\n\n## PR / Attribution\n\nPR #100 P3 F23. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"closed","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:07Z","created_by":"external-grok-bot","updated_at":"2026-09-21T21:58:17Z","closed_at":"2026-09-21T21:58:17Z","close_reason":"DOES NOT REPRODUCE -- closing invalid, on evidence.\n\nhidden_line reads two names; bare_name excludes '/', so quote_name already writes hidden: C/|A/B|. The grammar already forbade the hazard this bead describes.\n\nKept as a PASSING CONTROL rather than deleted: slash_in_hidden_name_survives_roundtrip now asserts it, so the bead cannot be re-filed without someone watching the property pass.\n\nSecond external-bot report in this area that did not reproduce (the first is the one AGENTS.md records at PR #21). Worth weighting bot-filed P3s accordingly.","external_ref":"gh-pr-100-P3-hidden","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-y8ym","title":"DeleteView effect discards error result","description":"## Summary\n\n`DeleteView` uses `let _ = app.model_state.workbook.delete_view(...)`, discarding `Result`. Failure (e.g. last view) produces no user error; code below assumes success.\n\n## Steps to Reproduce\n\n1. Attempt to delete the only view in a workbook.\n2. No error message; view may still exist while UI assumed deletion.\n\n## Acceptance Criteria\n\n- delete_view Err reaches status_msg (and abort if appropriate); success path only runs after Ok.\n\n## Location\n\n`crates/improvise-command/src/ui/effect.rs` ~L321\n\n## PR / Attribution\n\nPR #100 P3 F18. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:07Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:07Z","external_ref":"gh-pr-100-P3-deleteview","labels":["adversarial-review","pr-100"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-y8ym","title":"DeleteView effect discards error result","description":"## Summary\n\n`DeleteView` uses `let _ = app.model_state.workbook.delete_view(...)`, discarding `Result`. Failure (e.g. last view) produces no user error; code below assumes success.\n\n## Steps to Reproduce\n\n1. Attempt to delete the only view in a workbook.\n2. No error message; view may still exist while UI assumed deletion.\n\n## Acceptance Criteria\n\n- delete_view Err reaches status_msg (and abort if appropriate); success path only runs after Ok.\n\n## Location\n\n`crates/improvise-command/src/ui/effect.rs` ~L321\n\n## PR / Attribution\n\nPR #100 P3 F18. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","status":"open","priority":3,"issue_type":"bug","owner":"box@local","created_at":"2026-09-21T20:49:07Z","created_by":"external-grok-bot","updated_at":"2026-09-21T20:49:07Z","external_ref":"gh-pr-100-P3-deleteview","labels":["adversarial-review","pr-100"],"dependencies":[{"issue_id":"improvise-y8ym","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:13Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v4hg","title":"AddItem/AddFormula errors don't abort the effect batch","description":"## Summary\n\n`AddItem` / `AddItemInGroup` / `AddFormula` (parse-error branch) set `status_msg` on failure but do not set `abort_effects = true`, unlike `AddCategory`. Downstream effects in the batch still run (e.g. SetCell after failed AddItem).\n\n## Steps to Reproduce\n\n1. Run a Sequence: AddItem to non-existent category, then an effect that assumes the item exists.\n2. Status shows error but second effect still applies.\n\n## Acceptance Criteria\n\n- Item/formula construction failures abort the batch consistently with AddCategory (or document intentional asymmetry).\n\n## Doctrine context\n\n`invariant(an_abort_stops_the_chain_not_the_bookkeeping)`\n\n## Location\n\n`crates/improvise-command/src/ui/effect.rs` ~L102-110 (contrast AddCategory ~L87)\n\n## PR / Attribution\n\nPR #100 P3 F17. external Grok Bot from cloud adversarial review PR #100 for Ed Langley\n","notes":"Orchestrator 2026-09-23: #181 does NOT close v4hg. After #181's review, the one reachable key-path case left is AddFormula's parse-error arm. Enter in FormulaEdit on 'Total = (' drops the typed formula and lands in FormulaPanel. The proposed fix is refuse_and_stop on that arm, the way #181's earlier head did it. Route this after #181 merges.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-refuse-fu","owner":"box@local","created_at":"2026-09-21T20:49:06Z","created_by":"external-grok-bot","updated_at":"2026-09-23T14:56:21Z","started_at":"2026-09-23T05:16:53Z","closed_at":"2026-09-23T14:56:21Z","close_reason":"Fixed in PR #213, merged as 345a358 after adversarial review at 3929196. Follow-ups: improvise-we1a, improvise-rtud.","external_ref":"gh-pr-100-P3-additem","labels":["adversarial-review","pr-100"],"comments":[{"id":"b97686ec-00ec-5927-9636-7863b45447bc","issue_id":"improvise-v4hg","author":"agent-review-181","text":"From the adversarial review of PR #181 at 12171fb: the key-path half of this ticket, after cz8d settled on \"AddItem/AddFormula refuse but do not abort\".\n\n**AddFormula: yes, one case exists.** Enter in `FormulaEdit` resolves to `[commit-buffer formula, clear-buffer formula]`. `CommitBuffer` emits `[SetStatus(\"Formula added\"), AddFormula, change_mode(FormulaPanel)]`, and `on_commit` is documented as \"where a *successful* commit lands the user\". Probed through `App::handle_key` with `Total = (` at 12171fb, the result is mode `FormulaPanel`, the formula buffer `\"\"`, the status \"Formula error: …\", and `last_batch_refused() == true`. The error is visible, but **the user's typed formula is thrown away** by effects that assume the commit worked. This is the same behaviour main has always had, so it is not a regression. The fix is for AddFormula's parse-error arm to use `refuse_and_stop`. With that change, the earlier head of #181 kept the user in `FormulaEdit` with the text intact, and the error was drawn to the right of the prompt by `minibuffer_line`. The abort costs nothing on the wire, because a frame is rejected on the refusal either way. `fit-measure` emits `AddFormula` last, so it is unaffected.\n\n**AddItem: no reachable case found.** `commit-buffer item` has no `on_commit`, so the user stays in `ItemAdd`, and the only thing after it is `clear-buffer`. `rename_record_coordinate` does `AddItem` followed by moving the cells, but its category is a live records column, so AddItem cannot fail there. `add-items` repeats the same failure. In a `:` line like `add-item Regoin X . set-cell … Regoin/X`, the orphan cell is `set-cell`'s own missing validation, and it happens without the `add-item` too, so an abort is the wrong fix for it.\n","created_at":"2026-09-23T05:01:05Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} -{"_type":"issue","id":"improvise-pcje","title":"Render parity: the browser drops col_widths, column ancestry, every AxisColor and the tile overflow","description":"DERIVED at 1f28868 by a mechanical grep of every cache field across src/ui/*.rs,\nsrc/draw.rs, wasm-client/src/render.rs, web/app.js and web/shell.js.\n\nGOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields and\nevery nested field are read by app.js or shell.js. The four-widget gap that\ndesign-principles.md section 7 records as its motivating case is CLOSED. No\norphan DOM ids either.\n\nWhat differs, none of it silent-wrong, all of it cosmetic or structural:\n\n GridCache::col_widths TUI yes / browser NO -- never reaches RenderModel;\n the browser lets CSS table layout size columns\n GridCache::totals TUI yes / browser NO -- not on RenderModel at all\n GridCache::col_frame browser has no shares_ancestry equivalent, so the TUI's\n selected-column ancestry underline has no counterpart\n PageAxis::{cat,selected} TUI yes / browser NO\n CellDisplay::numeric TUI NO (it right-aligns every cell unconditionally,\n grid.rs:349) / browser yes\n CellDisplay::error read by NOBODY in src/, despite the field's doc saying\n \"rendered distinctly\"\n CellDisplay::id MIRROR-IMAGE PARTIALS: the TUI reads only Aggregate -\u003e\n italic; the browser collapses it to\n editable = matches!(id, Some(Record(_))) and so cannot\n tell Aggregate from Derived from None\n CategoryRow::axis TUI to_string() + axis_fg(axis.color());\n browser axis.initial(), NO COLOUR\n TileEntry::axis TUI short_label() + colour; browser label only\n TileBarCache::tiles TUI horizontal scroll with left/right overflow arrows;\n browser no scroll or overflow handling\n\nThe colour ones are one question, not four: AxisColor has no CSS counterpart at\neither site, unlike MinibufColor which crosses as a semantic enum. Answer it once\n(gotcha 26's shape: the decision is a semantic enum in the shared layer and only\nthe last translation is local) rather than per widget.\n\nCellDisplay::id's mirror-image partials are the one with teeth -- see the\nseparate ADR 002 / editable ticket.\n","notes":"GROOM 2026-09-21: SCOPE NARROWED -- this bead OVERLAPS improvise-p7lh and p7lh owns the intersection.\n\np7lh is the demonstrated one (probed against a real Host) and covers the two gaps that matter most: GridCache::totals (the column-totals strip) and page_coords (the page bar), both of which the server already projects and the browser's cache already holds correctly -- they are simply never painted. p7lh is also a blocker on improvise-bck, because the SHIPPED DEMO has a page axis and no page bar.\n\nWHAT REMAINS THIS BEAD'S, after removing totals and page_coords:\n - col_widths never reaches RenderModel; the browser lets CSS table layout size columns\n - col_frame has no shares_ancestry equivalent, so the TUI's selected-column\n ancestry underline has no browser counterpart\n - CategoryRow::axis and TileEntry::axis cross WITHOUT COLOUR -- AxisColor has\n no CSS counterpart at either site, unlike MinibufColor which does\n - TileBarCache::tiles has no scroll or overflow handling in the browser\n - CellDisplay::error is read by NOBODY in src/, despite the field's doc saying\n 'rendered distinctly'\n - CellDisplay::id: mirror-image partials -- the TUI reads only Aggregate, the\n browser collapses to editable = matches!(id, Some(Record(_)))\n\nThe colour items are ONE question, not four: answer it the way gotcha 26 already answers it for MinibufColor -- the decision stays a semantic enum in the shared layer and only the last translation is local -- rather than per widget.\n\nAlso worth carrying: the sweep found exposed-but-unpainted is EMPTY. All 22 RenderModel fields are read by app.js or shell.js, so the four-widget gap design-principles.md section 7 records as its motivating case is CLOSED.\nSCOPE NOTE 2026-09-21, and a correction to this bead's own framing.\n\nThis bead opens \"GOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields ... are read\". That was TRUE and it was ALSO the wrong question, which PR #113 demonstrated rather than argued.\n\nThe sweep behind this bead was CLOSED UNDER RenderModel -- it asked whether every field RenderModel exposes is painted. GridCache::totals and page_coords were projected, folded by the browser's cache, and painted by nothing, for the entire period between improvise-n10 and improvise-p7lh, and the sweep truthfully reported an empty set the whole time. A completeness check closed over the artefact that can OMIT a thing cannot see the omission.\n\nPR #113 closed that by adding the question one level up to web/tests/render_surface.test.js: every pub field of every WIDGET CACHE reaches a front end, or is named as debt.\n\nWHAT THAT MEANS FOR THIS BEAD: `grid.col_widths` is now the single named debt entry in that guard, and it points here. So this bead is no longer \"a list someone derived by grepping\"; it is the one item a running check reports. The rest of its original list -- column ancestry, AxisColor, tile overflow -- should be re-derived against the guard rather than trusted from the grep, because that grep had the same blind spot.\n\nThe guard's own residual hole is improvise-t3oh (a whole-cache hand-off proves the cache is consumed, never that every field of it is).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:29:06Z","labels":["browser","parity","ui"],"dependencies":[{"issue_id":"improvise-pcje","depends_on_id":"improvise-p7lh","type":"discovered-from","created_at":"2026-09-21T21:31:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pcje","title":"Render parity: the browser drops col_widths, column ancestry, every AxisColor and the tile overflow","description":"DERIVED at 1f28868 by a mechanical grep of every cache field across src/ui/*.rs,\nsrc/draw.rs, wasm-client/src/render.rs, web/app.js and web/shell.js.\n\nGOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields and\nevery nested field are read by app.js or shell.js. The four-widget gap that\ndesign-principles.md section 7 records as its motivating case is CLOSED. No\norphan DOM ids either.\n\nWhat differs, none of it silent-wrong, all of it cosmetic or structural:\n\n GridCache::col_widths TUI yes / browser NO -- never reaches RenderModel;\n the browser lets CSS table layout size columns\n GridCache::totals TUI yes / browser NO -- not on RenderModel at all\n GridCache::col_frame browser has no shares_ancestry equivalent, so the TUI's\n selected-column ancestry underline has no counterpart\n PageAxis::{cat,selected} TUI yes / browser NO\n CellDisplay::numeric TUI NO (it right-aligns every cell unconditionally,\n grid.rs:349) / browser yes\n CellDisplay::error read by NOBODY in src/, despite the field's doc saying\n \"rendered distinctly\"\n CellDisplay::id MIRROR-IMAGE PARTIALS: the TUI reads only Aggregate -\u003e\n italic; the browser collapses it to\n editable = matches!(id, Some(Record(_))) and so cannot\n tell Aggregate from Derived from None\n CategoryRow::axis TUI to_string() + axis_fg(axis.color());\n browser axis.initial(), NO COLOUR\n TileEntry::axis TUI short_label() + colour; browser label only\n TileBarCache::tiles TUI horizontal scroll with left/right overflow arrows;\n browser no scroll or overflow handling\n\nThe colour ones are one question, not four: AxisColor has no CSS counterpart at\neither site, unlike MinibufColor which crosses as a semantic enum. Answer it once\n(gotcha 26's shape: the decision is a semantic enum in the shared layer and only\nthe last translation is local) rather than per widget.\n\nCellDisplay::id's mirror-image partials are the one with teeth -- see the\nseparate ADR 002 / editable ticket.\n","notes":"GROOM 2026-09-21: SCOPE NARROWED -- this bead OVERLAPS improvise-p7lh and p7lh owns the intersection.\n\np7lh is the demonstrated one (probed against a real Host) and covers the two gaps that matter most: GridCache::totals (the column-totals strip) and page_coords (the page bar), both of which the server already projects and the browser's cache already holds correctly -- they are simply never painted. p7lh is also a blocker on improvise-bck, because the SHIPPED DEMO has a page axis and no page bar.\n\nWHAT REMAINS THIS BEAD'S, after removing totals and page_coords:\n - col_widths never reaches RenderModel; the browser lets CSS table layout size columns\n - col_frame has no shares_ancestry equivalent, so the TUI's selected-column\n ancestry underline has no browser counterpart\n - CategoryRow::axis and TileEntry::axis cross WITHOUT COLOUR -- AxisColor has\n no CSS counterpart at either site, unlike MinibufColor which does\n - TileBarCache::tiles has no scroll or overflow handling in the browser\n - CellDisplay::error is read by NOBODY in src/, despite the field's doc saying\n 'rendered distinctly'\n - CellDisplay::id: mirror-image partials -- the TUI reads only Aggregate, the\n browser collapses to editable = matches!(id, Some(Record(_)))\n\nThe colour items are ONE question, not four: answer it the way gotcha 26 already answers it for MinibufColor -- the decision stays a semantic enum in the shared layer and only the last translation is local -- rather than per widget.\n\nAlso worth carrying: the sweep found exposed-but-unpainted is EMPTY. All 22 RenderModel fields are read by app.js or shell.js, so the four-widget gap design-principles.md section 7 records as its motivating case is CLOSED.\nSCOPE NOTE 2026-09-21, and a correction to this bead's own framing.\n\nThis bead opens \"GOOD NEWS FIRST: exposed-but-unpainted is EMPTY. All 22 RenderModel fields ... are read\". That was TRUE and it was ALSO the wrong question, which PR #113 demonstrated rather than argued.\n\nThe sweep behind this bead was CLOSED UNDER RenderModel -- it asked whether every field RenderModel exposes is painted. GridCache::totals and page_coords were projected, folded by the browser's cache, and painted by nothing, for the entire period between improvise-n10 and improvise-p7lh, and the sweep truthfully reported an empty set the whole time. A completeness check closed over the artefact that can OMIT a thing cannot see the omission.\n\nPR #113 closed that by adding the question one level up to web/tests/render_surface.test.js: every pub field of every WIDGET CACHE reaches a front end, or is named as debt.\n\nWHAT THAT MEANS FOR THIS BEAD: `grid.col_widths` is now the single named debt entry in that guard, and it points here. So this bead is no longer \"a list someone derived by grepping\"; it is the one item a running check reports. The rest of its original list -- column ancestry, AxisColor, tile overflow -- should be re-derived against the guard rather than trusted from the grep, because that grep had the same blind spot.\n\nThe guard's own residual hole is improvise-t3oh (a whole-cache hand-off proves the cache is consumed, never that every field of it is).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T20:39:57Z","created_by":"Edward Langley","updated_at":"2026-09-22T02:29:06Z","labels":["browser","parity","ui"],"dependencies":[{"issue_id":"improvise-pcje","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-pcje","depends_on_id":"improvise-p7lh","type":"discovered-from","created_at":"2026-09-21T21:31:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-yae4","title":"Column width overflows before clamping a 65535-column text value","description":"Report-only hunt at 70025e2. No implementation changes.\n\nLocation: crates/improvise-command/src/ui/viewport.rs:66,85\n\nA stored 65535-character ASCII text value causes compute_col_widths/grid_body to panic at w+1 in debug builds. Width is narrowed to u16 before adding the cell gap and clamping to MAX_COL_WIDTH. In release the sum wraps and chooses a minimum-width column instead of the configured maximum. Longer widths can truncate during the earlier as u16 cast even when addition does not overflow. Clamp while still usize or use bounded/saturating conversion before arithmetic. Test widths around 65535 and 65536 as well as ordinary text. Duplicate checks: width, overflow, 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;\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 wide_cell_clamps_without_overflow() {\n use improvise::{ui::app::App,model::cell::{CellKey,CellValue}};\n let mut w=revenue_book();\n w.model.set_cell(CellKey::new(vec![(\"Region\".into(),\"East\".into()),(\"_Measure\".into(),\"Revenue\".into())]),CellValue::Text(\"x\".repeat(65535)));\n let app=App::new(w,None);\n let widths=improvise::ui::viewport::compute_col_widths(\u0026app.model_state.workbook.model,\u0026app.layout,false,0);\n assert!(widths.iter().all(|w| *w\u003c=32));\n}\n```\nRun cargo test wide_cell_clamps_without_overflow -- --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":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:37:41Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","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\nCapped while still usize in the one place every measurement enters (widen). Behaviour identical for every non-overflowing input.\n\nTHE SECOND INSTANCE, which the bead did not name and which was the more dangerous: improvise_protocol::header_level_widths had byte-for-byte the same defect, on the axis gotcha 44 calls the one home for the width rule -- and it is in the PROTOCOL, so it is shared by both front ends. Fixing only the site the bead named would have left the row header and the column widths with two different answers to the same arithmetic.\n\nThe release symptom is the one to lead with, not the panic: the WIDEST value in the document silently rendering at the NARROWEST column width, with no failing test anywhere. Faults injected: debug -\u003e 'attempt to add with overflow'; release -\u003e left [5], right [32], the wrap reported as the minimum width.\n\n'as u16 followed by + 1 and .clamp' is a greppable shape and the grep finds both.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l3hm","title":"[bug] i on CellId::Create is a silent black hole; Aggregate/Derived decline out loud","description":"## Summary\n`i` / `a` on a `CellId::Create` (records-mode `+` column) is a silent black hole (`KeyOutcome` upstream/none), while Aggregate and Derived decline out loud with `SetStatus`. Same doctrine (`an_upstream_key_is_a_black_hole`) that drove loud declines for the other non-editable ids.\n\n## Repro (reasoning / unit-shaped)\n1. Records mode with a creating column under the cursor (`CellId::Create`).\n2. Press `i`.\n3. Observe: no status, no mode change — key appears dead.\n\n## Expected\nA local `SetStatus` naming that creating/naming a column is not available in the browser yet (same family as Aggregate’s drill message and Derived’s formula message), per clients.md loud-decline doctrine.\n\n## Actual\n`edit_or_drill` match arm `CellId::Create(_) =\u003e None` in `crates/improvise-protocol/src/resolve.rs`, which becomes Upstream/Ignored rather than Local SetStatus. Comment cites improvise-poia (projection identity; closed) — the loud-decline gap remains.\n\n## Evidence\n- Code hunt by external Grok Bot agent for Ed Langley, 2026-09-21 PT.\n- Contrast: `i_on_an_aggregate_declines_out_loud` / `i_on_a_derived_cell_names_the_formula` in the same file.\n- Closed improvise-poia / improvise-gcvb covered identity/commit, not this decline wording.\n\n## Notes\nFound by an external Grok Bot agent smoke/code hunt for Ed Langley (not Claude). Not an upstream black-hole key that is documented as intentionally silent for host features like `[`/`]`/drill wire — this is the client’s own edit-or-drill door answering None.\n","status":"open","priority":3,"issue_type":"bug","created_at":"2026-09-21T20:32:39Z","created_by":"box","updated_at":"2026-09-21T20:32:39Z","dependencies":[{"issue_id":"improvise-l3hm","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-pj8a","title":"The browser paints the host's hints and help over a smaller capability set, and ten keys are silent","description":"Found by the UX audit. ASSERTED (read: keymap + declined() + Upstream list + help pages).\n\nThe browser paints the HOST'S hint line and the HOST'S help pages over a smaller capability set. Three tiers.\n\n## (a) Loud — honest, but reads as broken\nSix commands are declined() with \"... is not available in the browser yet\": drill-into-cell, toggle-prune-empty, toggle-records-mode, add-record-row, delete-formula-at-cursor, view-back.\n\nFOUR OF THEM ARE ADVERTISED BY THE HINT LINE THE BROWSER ITSELF PAINTS: R:records, P:prune, \u003e:drill, and Del/d:delete in the FORMULAS line. The footer offers a key and the key answers no. Note you can ADD a formula in the browser but not DELETE one.\n\n## (b) Silent — the worse tier\nThe Upstream list resolves to ClientFrame::Key, which Host::handle answers with Vec::new() (ui/host.rs:82). Nothing happens, nothing is said: / n N, Enter (enter-advance), Ctrl-S, Ctrl-Q, z, H, and the g/y/Z prefixes.\n\nThe browser paints the SHARED help overlay, so HELP PAGE 2'S ENTIRE \"SEARCH\" SECTION, and page 3's \"Enter — Edit current cell (same as i)\", ARE TAUGHT TO A BROWSER USER AND DO NOTHING. Enter is the sharpest: on the same help page, `i` works and its documented synonym is inert.\n\nThere is also no which-key popup in the browser, since prefixes go Upstream — so g, y, Z are dead AND unexplained, where the TUI pops a menu.\n\n## (c) Dropped entirely\nCovered by the sibling ticket on page_coords and totals.\n\n## Acceptance Criteria\n- Extend declined() to cover the Upstream names with a status, exactly as (a) does. That turns \"broken\" into \"not here yet\" for ten keys and costs one match arm each. THIS IS THE HIGH-VALUE HALF.\n- Decide what the browser's help overlay should show: either mark the unavailable sections, or derive the pages per surface. The pages are already derived from the keymap (help.rs), so a capability filter is the natural shape.\n- The hint line should not advertise a key the surface declines.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:20Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:30:20Z","dependencies":[{"issue_id":"improvise-pj8a","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:45Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rr8n","title":"The tile bar's permanent hint is false, and the status bar contradicts the wizard on four of eight steps","description":"Found by the UX audit. Two findings about on-screen text that is wrong or contradictory. Both ASSERTED (read against the keymap), both cheap to confirm in a terminal.\n\n## 1 — the tile bar's permanent hint is FALSE\nsrc/ui/tile_bar.rs:140: let hint = \" Ctrl+^v\u003c-\u003e to move tiles\";\n\nThose four keys are bound to enter-tile-select, not to moving anything (keymap.rs:632):\n normal.bind(KeyCode::Left, ctrl, \"enter-tile-select\"); // and Right/Up/Down\n\nPressing Ctrl-Right ENTERS TILES MODE; nothing moves. This is the only persistent on-screen documentation of those keys and it describes a different command. It also spells `Ctrl+` where keymap::key_label — and therefore every derived hint and help row — spells `Ctrl-`.\n\nFix: \" Ctrl-^v\u003c-\u003e or T to pick a tile\".\n\n## 2 — four hint dialects, two on screen at once, disagreeing\ndraw.rs::draw() stacks the tile bar (row 3) above the status bar (row 4), so in tile-select mode the user sees on adjacent lines:\n\n tile bar: [Enter] cycle axis [r/c/p] set axis [\u003c-\u003e] select [Esc] cancel\n status bar: hl\u003c-\u003e:select Enter/Space:cycle r:row c:col p:page Esc/Tab:back\n\nDifferent notation, different content — the tile bar omits Space, h/l and Tab.\n\nThe import wizard has the same overlap, and there the status bar is WRONG ON FOUR OF EIGHT STEPS:\n\n NameModel overlay: Enter build, \u003c- back, Esc cancel | status: Space:toggle c:cycle | TRUTH: Space and c TYPE INTO THE NAME\n DefineFormulas overlay: n new, d delete, Enter next | status: Space:toggle c:cycle | TRUTH: n/d; Space and c do nothing\n Done overlay: Enter accept, \u003c-/Esc back | status: Enter:next Esc:cancel| TRUTH: Esc goes BACK, does not cancel\n Preview overlay: Press Enter to continue | status: Space:toggle c:cycle | TRUTH: Enter/Space advance\n\nThe `Done` row is an outright contradiction: the status bar tells the user Esc cancels when it goes back.\n\n## Acceptance Criteria\n- Fix the tile-bar string and its Ctrl+ / Ctrl- spelling.\n- SUPPRESS the status-bar hint when an overlay that draws its own is up — make hint_tail(ImportWizard) empty and skip draw_status's hint fallback in TileSelect. The tile bar and wizard already draw correct, local, per-state hints, so the cheap repair is to stop drawing the second table rather than to maintain it.\n- Longer term the wizard's per-step keys belong in advertised() keyed by step, which makes them derived like everything else.\n\nNote the wizard's back path itself is SOUND: Left steps back from every step (every_step_binds_left_as_back proves it), Done's Esc goes back rather than discarding. The only hole is Left being dead while a formula is typed, survivable because Esc cancels the edit first.\n","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-tui","owner":"el-github@elangley.org","created_at":"2026-09-21T20:30:19Z","created_by":"Edward Langley","updated_at":"2026-09-23T02:00:33Z","started_at":"2026-09-23T01:37:05Z","closed_at":"2026-09-23T02:00:33Z","close_reason":"PR #149, with a DELIBERATE INVERSION of the acceptance criteria for tile select, accepted by the orchestrator. The ticket asked to rewrite the tile-bar literal and suppress the derived status hint; the agent deleted BOTH tile-bar literals instead, on the ticket's own finding 2 -- the derived status line is a strict superset (Space, h/l, Tab appear only there) and cannot go stale, so suppressing it would delete information and keep the table that had just rotted. Verified before accepting: the literal read 'Ctrl+arrows to move tiles' while those keys are bound to enter-tile-select, so it was already false. This follows design-principles section 5 directly (a derivation done twice is the defect; sharing the table beats testing two copies). The import wizard keeps the ticket's ORIGINAL answer, because its derived line comes from the mode keymap while the real keys are per-step inside HandleWizardKey. NOT PREDICTED: the shipped characterization test renders at 80 columns, where the hint does not fit and is never drawn -- the one test pinning that widget could not see either hint. (--force: agent-tui handed back.)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ppru","title":"Nondefault measure aggregation is lost by the .improv roundtrip","description":"Report-only bug hunt at HEAD 70025e2, 2026-09-21. No implementation changes.\n\nLocation: crates/improvise-io/src/persistence/format.rs; crates/improvise-core/src/model/types.rs:234,473\n\nSet model.measure_agg[Price] to AggFunc::Avg; after parse_md(format_md(wb)) measure_agg is empty and lookup falls back to Sum. This is API/legacy-JSON reachable: no current production setter for measure_agg was found, so priority is lower than ordinary UI persistence defects. Add a grammar-backed representation or explicitly resolve this unsupported state. Duplicate checks: measure_agg, aggregation, average, persist.\n\n## Steps to Reproduce\nA standalone Rust 2024 test package depends on improvise-io by path. Imports:\n```rust\nuse improvise_io::{workbook::Workbook,persistence::{format_md,parse_md},formula::AggFunc};\nuse improvise_io::import::csv_parser::parse_csv_bytes_with_warnings;\n#[test] fn aggregation_survives_save() {\n let mut w=Workbook::new(\"test\"); w.add_item(\"_Measure\",\"Price\").unwrap();\n w.model.measure_agg.insert(\"Price\".into(),AggFunc::Avg);\n let r=parse_md(\u0026format_md(\u0026w)).unwrap(); assert!(matches!(r.model.measure_agg.get(\"Price\"),Some(AggFunc::Avg)), \"loaded aggregation: {:?}\",r.model.measure_agg);\n}\n```\nRun cargo test aggregation_survives_save. This test failed against the unchanged source.\n\n## Acceptance Criteria\nThe focused test passes after correction, with a control covering the unchanged ordinary case. Preserve the behavior described above without silent data loss.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:27:45Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:16Z","closed_at":"2026-09-21T21:58:16Z","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\nNew ## Aggregation section, written SORTED because measure_agg is a HashMap (gotcha 18). The ticket's 'no current production setter' hid a second defect: writing it naively is a gotcha-18 bug the moment anything sets more than one entry. The low priority was about reachability, not about the fix being trivial.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-el9h","title":"The server's mirror settles its mode once and never again, so it diverges from the client it mirrors","description":"Found by the command-layer bug hunt. ASSERTED, with the settling test specified.\n\nClientSession::fold (project.rs:711) settles self.view.mode against self.grid.records — the grid LAST PROJECTED to that client. project_diff (project.rs:758) updates self.grid AFTER the fold and never re-settles.\n\nThe client side does not have this shape: ClientState::fold calls settle_mode() on EVERY fold including Command::Project, so when the new view's grid projection arrives the client re-settles and is correct.\n\nSo for [SwitchView(records-shaped view), ChangeMode(Normal)] — precisely the case PR #98 exists to fix — THE MIRROR KEEPS Normal WHILE THE CLIENT ENDS ON RecordsNormal. That commit's own message says the mirror was given the settle \"so the two cannot diverge in a field nobody currently reads\"; the mechanism as written does not achieve it.\n\nSecond, smaller instance of the same shape: apply_switch (project.rs:930), adopt_deleted_views and adopt_cursor_epoch all write the mirror with bare reduce_view, bypassing fold and therefore the settle — three doors past the one documented as \"the same function the client runs\".\n\n## Why P3\nClientSession::view() has no production caller (grepped across improvise-command, improvise-ws-server, improvise-worker-server), and reseed/connect rebuild the view from fresh_view(app) rather than from the folded mirror, so no Snapshot currently carries the wrong value. It is a correctness hole waiting for its first reader — which is exactly the shape improvise-e5pf was before someone read it.\n\n## Acceptance Criteria\n- The settling test, which is specified and cheap: drive a Host with a pivot-shaped default view and a second records-shaped view, Sessions::connect, send ClientFrame::Commands([SwitchView(\"records view\"), ChangeMode(Normal)]), and compare session.view().mode against a ClientState fed the resulting ServerFrames in arrival order. ClientSession::view is pub so this is reachable from a test in project.rs.\n- Then the one-line fix: re-settle at the end of project_diff after self.grid = desired. Or make fold the only writer of the mirror's ViewState and settle there plus after each projection — which also closes the three bypass doors.\n- Do NOT fix before the test exists: the current behaviour is unobservable, so a fix without a test is unfalsifiable.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:25:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:44Z","closed_at":"2026-09-21T21:58:44Z","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\nsettle_mode now called from fold AND at the end of project_diff, which also covers the three bare-reduce_view doors. The test was written first and failed exactly as the bead predicted (client RecordsNormal, mirror Normal).\n\nNote the fixture trap found on the way: a fresh View::new carries no axis entry for _Index/_Dim, so is_records_mode can never fire on one. Without the precondition assertion the test would have compared two Normals and PASSED against the unfixed code.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-j79v","title":"Four click surfaces still use click, so a gesture from an open editor is swallowed","description":"Found by the browser bug hunt. ASSERTED (read, not run) — what would settle it is stated below.\n\nimprovise-0x9u fixed the grid's cell listener: web/app.js:321 is on MOUSEDOWN with event.preventDefault(), and the comment at :318 gives the reason — from an editing state the focused \u003cinput\u003e's blur repaints first and the element is detached before click can fire.\n\nTHE OTHER FOUR CLICK SURFACES KEPT `click`:\n web/app.js:396 paintPanel (view and formula panel rows)\n web/app.js:436 paintTiles (the tile bar)\n web/app.js:453 categoryList (category panel rows)\n\n## Steps to Reproduce\nOpen the demo. Click an editable cell — the inline \u003cinput\u003e opens and takes focus. Now click a tile in the tile bar (or a category/view/formula panel row).\n\nMousedown's default action moves focus off the \u003cinput\u003e -\u003e blur -\u003e dismiss(false) -\u003e close() -\u003e painted = -1 -\u003e repaint() -\u003e replaceChildren(). The element the user pressed is now detached, mouseup lands elsewhere, and NO click event reaches the listener.\n\nThe gesture is swallowed: the first click only closes the editor. The user clicks twice for one action, or concludes the tile bar is dead.\n\n## Acceptance Criteria\n- The four listeners become mousedown + preventDefault, matching the td arm. A shared helper would be better; the four are identical in shape.\n- A case in harness/tests/shell.spec.js (the right home per gotcha 48): open the inline editor on #grid td.editable, click #tiles .tile, assert the tile's axis changed after ONE click.\n- Prove the mechanism is the one named: revert the td listener to click and confirm the existing commit test fails.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-i1wi","title":"scrolled_to drops viewport_effects' max(1) guard, so click_cell underflows on a zero window","description":"Found by the protocol bug hunt. DEMONSTRATED (debug build).\n\nresolve.rs:525 and :531 —\n\n row_offset = nr.saturating_sub(window_rows - 1);\n col_offset = nc.saturating_sub(window_cols - 1);\n\nIts original, improvise-command/src/command/cmd/navigation.rs:48, has\n\n let vr = visible_rows.max(1);\n let vc = visible_cols.max(1);\n\nTHE MIRROR DROPPED THE GUARD. grid_move (resolve.rs:497) checks window_rows == 0 || window_cols == 0 and returns early — but click_cell (:167) does NOT, and it calls scrolled_to directly.\n\n ClientState::default() with grid.extent = {rows:5, cols:5}, window left at (0,0)\n click(\u0026state, Click::Cell{row:1, col:1})\n -\u003e panicked at panic_const_sub_overflow\n resolve::scrolled_to resolve.rs:525\n resolve::click_cell resolve.rs:176\n\nIn a release wasm build (overflow-checks off) it does not panic: 0usize - 1 wraps and both offsets silently collapse to 0.\n\n## Reachability\nLow from the shipped page. shell.js::extentOf clamps to Math.max(1, ..) and app.js sends set_viewport before any grid arrives, so window == (0,0) needs a click before the handshake. The frame-only Client::viewport twin is deleted (improvise-seik). So this is a latent panic in a pub API and a real mirror divergence, not a live user bug.\n\n## Why it matters anyway\ndoctrine(mirror_the_cmd_line_for_line) is the rule these resolvers live under, and the whole point is that a resolver reproduces its Cmd INCLUDING where siblings disagree. A dropped .max(1) is the mirror silently disagreeing, and tests/lockstep.rs did not catch it because no fixture has a zero window.\n\n## Acceptance Criteria\n- One line at resolve.rs:517: let (window_rows, window_cols) = (state.window.0.max(1), state.window.1.max(1));\n- A lockstep case with a zero window, or an explicit note in the evidence that no fixture has one and why.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:49Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:49Z","dependencies":[{"issue_id":"improvise-i1wi","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-j79v","title":"Four click surfaces still use click, so a gesture from an open editor is swallowed","description":"Found by the browser bug hunt. ASSERTED (read, not run) — what would settle it is stated below.\n\nimprovise-0x9u fixed the grid's cell listener: web/app.js:321 is on MOUSEDOWN with event.preventDefault(), and the comment at :318 gives the reason — from an editing state the focused \u003cinput\u003e's blur repaints first and the element is detached before click can fire.\n\nTHE OTHER FOUR CLICK SURFACES KEPT `click`:\n web/app.js:396 paintPanel (view and formula panel rows)\n web/app.js:436 paintTiles (the tile bar)\n web/app.js:453 categoryList (category panel rows)\n\n## Steps to Reproduce\nOpen the demo. Click an editable cell — the inline \u003cinput\u003e opens and takes focus. Now click a tile in the tile bar (or a category/view/formula panel row).\n\nMousedown's default action moves focus off the \u003cinput\u003e -\u003e blur -\u003e dismiss(false) -\u003e close() -\u003e painted = -1 -\u003e repaint() -\u003e replaceChildren(). The element the user pressed is now detached, mouseup lands elsewhere, and NO click event reaches the listener.\n\nThe gesture is swallowed: the first click only closes the editor. The user clicks twice for one action, or concludes the tile bar is dead.\n\n## Acceptance Criteria\n- The four listeners become mousedown + preventDefault, matching the td arm. A shared helper would be better; the four are identical in shape.\n- A case in harness/tests/shell.spec.js (the right home per gotcha 48): open the inline editor on #grid td.editable, click #tiles .tile, assert the tile's axis changed after ONE click.\n- Prove the mechanism is the one named: revert the td listener to click and confirm the existing commit test fails.\n","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:24:14Z","created_by":"Edward Langley","updated_at":"2026-09-21T20:24:14Z","dependencies":[{"issue_id":"improvise-j79v","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-qx1z","title":"hidden_items and collapsed_groups outlive the item or category they name","description":"Found by the core bug hunt, DEMONSTRATED, two arms of one asymmetry.\n\nA view's hidden_items and collapsed_groups outlive the item or category they name, so re-adding it brings it back in the hidden/collapsed state nobody asked for.\n\n## Arm 1 — hidden_items survives an item removal\nWorkbook::remove_item (workbook.rs:92) calls Model::remove_item (model/types.rs:331), which removes the item and its cells and TOUCHES NO VIEW AT ALL. hidden_items[\"Region\"] keeps \"East\".\n\n hide Region/East -\u003e remove item East -\u003e re-add East\n =\u003e view.is_hidden(\"Region\",\"East\") == true\n\nThe item is in the model, visible in the category panel, and ABSENT FROM THE GRID with no indication why.\n\n## Arm 2 — collapsed_groups survives a category removal\nView::on_category_removed (view/types.rs:92) clears category_axes, page_selections and hidden_items — but NOT collapsed_groups. The other three lines are right there, which is what makes this an omission rather than a decision.\n\n collapse Month/Q1 -\u003e remove category Month -\u003e re-add Month with Jan[Q1]\n =\u003e view.is_group_collapsed(\"Month\",\"Q1\") == true\n\nThe group comes back pre-collapsed.\n\nBoth reachable from shipped keys: hide an item, delete the item or category from the category panel, re-add it.\n\n## Acceptance Criteria\n- Arm 2 is one line: `self.collapsed_groups.remove(cat_name);` in View::on_category_removed.\n- Arm 1 wants a View::on_item_removed(cat, item) dropping the hidden_items entry, called from Workbook::remove_item over every view — Workbook is already the stated home of cross-view ops (gotcha 6, the item facade).\n- A test per arm. Neither is covered today.\n- While there, check the same question of page_selections: does a page selection naming a removed item survive?\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:04Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:16Z","closed_at":"2026-09-21T21:58:16Z","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\nView::on_item_removed added and called from Workbook::remove_item over every view; on_category_removed now clears collapsed_groups. Both arms plus the third question the bead asked (page selection).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-hsoe","title":"check-evidence cannot see a duplicate heading, and three exist","description":"Found by the Wave I agent while merging PR #92, which had accumulated TWO entries headed `### a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths` — the original, describing a fixture that no longer existed and repeating a claim the adversarial review had measured false, plus the superseding one. A reader hitting the first would have taken it as current.\n\n--check-evidence CANNOT SEE THIS. It verifies that a heading names a test that runs, and a duplicate heading names one just as well. Both copies pass.\n\n## Three already on main, verified 2026-09-21\n ### a_layout_change_moves_the_layout_version_and_no_record (test-evidence/command.md)\n ### the_two_front_ends_draw_the_same_column_header (test-evidence/tui.md)\n ### the_whole_frame_is_unchanged (test-evidence/tui.md)\n\nReproduce:\n grep -rhoE '^### [A-Za-z_:][A-Za-z_0-9:]*$' context/test-evidence/*.md | sort | uniq -d\n\n## Why it matters beyond tidiness\ncontext/test-evidence records WHY a test can fail. Two entries under one heading means one of them is stale by construction — they cannot both describe the current test — and nothing indicates which. That is the same class as an evidence entry whose mechanism measurement contradicts (doctrine an_evidence_entry_that_measurement_contradicts_is_worse_than_none): a reader gets a confident wrong answer rather than no answer.\n\n## Acceptance Criteria\n- --check-evidence refuses a duplicate heading, naming the file and both line numbers. It should refuse rather than warn, per the precedent set for ambiguous leaf names in improvise-rm4s.\n- Prove it by injection: add a second copy of an existing heading and watch the checker fail by name.\n- Resolve the three above. Each needs a read to decide which copy is current, or whether they describe genuinely different aspects and want distinct names.\n- Consider whether the same check should apply ACROSS files, not only within one: a heading is keyed to a test name, and test names are unique workspace-wide once module-qualified (improvise-rm4s), so two files claiming the same test is also a defect.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T12:39:28Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:26Z","closed_at":"2026-09-21T18:04:26Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-swre","title":"grid_of recomputes an unchanged grid every frame; memoise on the tiers that already exist","description":"Split out of improvise-jmjz on the adversarial review of PR #92, which closed jmjz on its own acceptance criterion (fuse the passes) and argued this is the separate, larger win.\n\ngrid_of recomputes the whole grid from scratch EVERY FRAME, even when nothing moved. After jmjz that is exactly one pass rather than three — 95ms at 18,000 cells, 1.32s at 90,000 — but the common case during scrolling, panel toggling and cursor movement is that the grid has not changed at all.\n\n## Why this is cheap to do here\nThe invalidation signal ALREADY EXISTS and is maintained for other reasons:\n- document_generation, layout_version, record_versions (gotcha 40, ADR 001's three tiers)\n- improvise-mcq4's view_shapes / view_frame observation\n\nA GridBody memo keyed on those takes an unchanged grid to roughly zero.\n\n## Why the remaining pass cannot be narrowed instead\nRuled out twice over, and the review measured it: totals sum EVERY row including ones a viewport scrolled past (gotcha 44), and widths are content-adaptive over every row. The benchmark's windowing ratio stayed 1.2-1.4x precisely because the surviving pass is the one a viewport cannot narrow. improvise-i5tk's decline stands.\n\n## Acceptance Criteria\n- A memo on the projected grid body keyed on the existing invalidation tiers; no NEW tier invented, because a second description of \"has the document changed\" is the disease (see the reasoning behind gotcha 39 and 45, where the trigger is the counter moving rather than a table of command variants).\n- Benchmarked: an unchanged grid across frames should approach zero, and the first frame must not regress.\n- The negative arm matters as much as the positive: a test that a grid which DID change is recomputed. A memo that never invalidates is fast and wrong, and it is the failure mode nothing will notice.\n\n## Second-order, much bigger, not this ticket\n~5us per cell at 18k is dominated by evaluate_aggregated doing a matching_values set intersection per cell (gotcha 18). An incremental aggregate index is the deeper fix. 90,000 cells at 1.32s remains unusable; that case is labelled pathological and memoisation helps it most.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T11:30:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:30:15Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-swre","title":"grid_of recomputes an unchanged grid every frame; memoise on the tiers that already exist","description":"Split out of improvise-jmjz on the adversarial review of PR #92, which closed jmjz on its own acceptance criterion (fuse the passes) and argued this is the separate, larger win.\n\ngrid_of recomputes the whole grid from scratch EVERY FRAME, even when nothing moved. After jmjz that is exactly one pass rather than three — 95ms at 18,000 cells, 1.32s at 90,000 — but the common case during scrolling, panel toggling and cursor movement is that the grid has not changed at all.\n\n## Why this is cheap to do here\nThe invalidation signal ALREADY EXISTS and is maintained for other reasons:\n- document_generation, layout_version, record_versions (gotcha 40, ADR 001's three tiers)\n- improvise-mcq4's view_shapes / view_frame observation\n\nA GridBody memo keyed on those takes an unchanged grid to roughly zero.\n\n## Why the remaining pass cannot be narrowed instead\nRuled out twice over, and the review measured it: totals sum EVERY row including ones a viewport scrolled past (gotcha 44), and widths are content-adaptive over every row. The benchmark's windowing ratio stayed 1.2-1.4x precisely because the surviving pass is the one a viewport cannot narrow. improvise-i5tk's decline stands.\n\n## Acceptance Criteria\n- A memo on the projected grid body keyed on the existing invalidation tiers; no NEW tier invented, because a second description of \"has the document changed\" is the disease (see the reasoning behind gotcha 39 and 45, where the trigger is the counter moving rather than a table of command variants).\n- Benchmarked: an unchanged grid across frames should approach zero, and the first frame must not regress.\n- The negative arm matters as much as the positive: a test that a grid which DID change is recomputed. A memo that never invalidates is fast and wrong, and it is the failure mode nothing will notice.\n\n## Second-order, much bigger, not this ticket\n~5us per cell at 18k is dominated by evaluate_aggregated doing a matching_values set intersection per cell (gotcha 18). An incremental aggregate index is the deeper fix. 90,000 cells at 1.32s remains unusable; that case is labelled pathological and memoisation helps it most.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T11:30:15Z","created_by":"Edward Langley","updated_at":"2026-09-21T11:30:15Z","dependencies":[{"issue_id":"improvise-swre","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:06Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4dpj","title":"The RecordsNormal-to-Normal arm of settle_normal_mode has zero coverage, and it is live","description":"Two gaps in settle_normal_mode's coverage, both live code with zero tests. The second was raised by ChatGPT Codex on PR #91 and is recorded here because the behaviour turned out CORRECT and only the guard is missing.\n\n## Gap 1 — the RecordsNormal-to-Normal arm (found by injection)\nReplacing settle_normal_mode's guard with a one-way version — matching only AppMode::Normal, dropping the RecordsNormal arm — leaves THE ENTIRE WORKSPACE SUITE PASSING.\n\nThe existing crossing test drives the LAYOUT both ways, but both branches end with `enter-mode normal`, so the mode handed to the settle is AppMode::Normal in BOTH directions. It discriminates \"settles at all\" and \"settles against the ending layout\", but not \"settles from either mode\".\n\nThe arm is live, proved reachable in production:\n\n records mode -\u003e apply_effects([SetAxis { \"_Index\", Axis::None }])\n layout.is_records_mode() == false, mode was RecordsNormal\n -\u003e settle yields AppMode::Normal\n\nOne extra case in the existing loop — seed the mode as RecordsNormal, cross OUT — closes it, and the one-way injection must then fail by name.\n\n## Gap 2 — the SwitchView path, raised by Codex and VERIFIED CORRECT\nCodex reviewed PR #91 at c186909 (round 1, the per-effect resolution) and pointed out that SwitchViewAtCursor emits [SwitchView(target), ChangeMode(Normal)]: open the view panel over a records view, select a pivot view, and round 1 would leave RecordsNormal over the pivot grid, with the inverse leaving Normal over records.\n\nRound 2 (a10e95e) moved the settle to ONE call after rebuild_layout at the end of apply_effects, so it resolves against the POST-batch layout. Verified on main at ce895ff with a throwaway probe driving a real `:` line that crosses records-ness within one batch:\n\n start_records=true -\u003e ended_records=false mode=Normal\n start_records=false -\u003e ended_records=true mode=RecordsNormal\n\nCorrect in both directions. So the behaviour Codex named is fixed.\n\nTHE GUARD IS STILL MISSING. Codex asked specifically for \"a regression that switches between records and pivot views through the view panel\", and no such test exists. The path it names is different from the one the crossing test drives (toggle-records-mode, not SwitchView), and records-ness is auto-detected from the axes (gotcha 1), so a view switch can change it.\n\n## Acceptance Criteria\n- The gap-1 case added to the existing loop; the one-way-guard injection fails by name.\n- A regression driving SwitchViewAtCursor over the view panel, records view to pivot view AND back, asserting the mode matches the grid the batch ends on.\n- Both must fail if the settle is moved back before rebuild_layout.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:29:18Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:27Z","closed_at":"2026-09-21T18:04:27Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-26dm","title":"The fourth length-not-completeness list: cases.len() == 13 in ui/effect.rs","description":"improvise-5ubs is closed for the two CYCLIC all() functions, by reading serde's\nderive-generated variant list out of its \"unknown variant ... expected one of\"\nmessage and diffing it against the names the cycle reached. That closes the\nAppMode and Rejection halves and the mechanism generalises to any serde enum.\n\nIt does NOT reach the FOURTH instance 5ubs records in its notes:\nlayout_geometry_effects_actually_change_document_config in\ncrates/improvise-command/src/ui/effect.rs, whose assert_eq!(cases.len(), 13) is\nover a hand-built list of EFFECTS, not of enum variants. serde has nothing to\nsay about it: an effect is a struct implementing a trait, so there is no derived\nenumeration to diff against.\n\nThe general statement stands and is the reason this is worth its own ticket:\ndoctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness).\nA count catches a case being DELETED from the list and never one being ABSENT\nfrom it, and the two are written identically as an assert_eq over a number. The\nadversarial review of PR #78 added a fourteenth pub struct declaring\nEffectKind::Model(ModelChange::LayoutGeometry), pointed at by nothing, and the\nwhole suite stayed green.\n\nWhat would close it: an enumeration of the effects the compiler maintains. The\ncandidates are a proc-macro over the impl blocks, or inventory-style\nregistration, or reading the Debug names off a derived list the way\nevery_model_command_names_the_effect_of_the_same_name already does for the\nModelCommand mapping -- that last one is the closest existing precedent in this\nrepo and is worth looking at first.\n\nNot fixed in wave I because ui/effect.rs was another wave's ground that round,\nand because a fourth hand-rolled attempt in a fourth place is the disease 5ubs\nnames.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T11:09:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T18:04:28Z","closed_at":"2026-09-21T18:04:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e8no","title":"A wide numeric category now refuses a fit that used to ignore it","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":"STALE-OPEN. Its acceptance -- a budget that DROPS a column rather than refusing the table -- is implemented and passing. Verified by the orchestrator by running them: improvise-gp fit::tests::a_wide_numeric_category_is_dropped_rather_than_refusing_the_fit (crates/improvise-gp/src/fit.rs:735) and the_budget_drops_the_widest_category_and_keeps_the_narrow_one both pass at 1ae2d2e, 1 passed each.\n\nFound by the improvise-k844 agent, which noticed because THIS TICKET'S BODY HAD BEEN COPIED INTO k844's DESCRIPTION -- so it read e8no's text while working k844's title, and checked whether e8no was done. The tracker defect is what surfaced the stale-open.\n\nEighth stale-open found today. See doctrine(a_ready_ticket_may_already_be_fixed).","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T10:55:32Z","created_by":"Edward Langley","updated_at":"2026-09-22T23:11:20Z","closed_at":"2026-09-22T23:11:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-sxsf","title":"cargo test -- --list cannot name the binary, so two crates could share a full test path","description":"MEASURED 2026-09-21 (PR #88). Re-verified latent: 1444 names, zero duplicate full paths, zero duplicate (crate, path) pairs.\n\nTHE PROPOSED FIX IS DECLINED ON TWO INDEPENDENT GROUNDS, and the second is the interesting one:\n\n1. COST: 45.2s against 2.4s for the one workspace call (11 members, warm) — 18x. And --check-evidence needs the --ignored twin, so the honest figure is 22 cargo invocations.\n\n2. IT DOES NOT CLOSE THE CLASS IT NAMES. A package is not a binary. The improvise crate alone has FIVE integration test binaries beside its lib, and an integration test's top-level fn lists with NO MODULE PATH AT ALL — 60 of 1444 names are bare. So '-p improvise' prefixes all six identically and tests/a.rs::foo still cannot be told from tests/b.rs::foo.\n\nAny real fix is per-TARGET (--lib, --test \u003cname\u003e), not per-package. Whoever takes this should start there.\n\nWorth noting how this was found: the agent set out to time the per-package fix and discovered it does not work while reading the target list. The cost measurement was the excuse for reading it; the correctness finding was the payoff — an argument for measuring declines rather than asserting them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:49:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-sxsf","title":"cargo test -- --list cannot name the binary, so two crates could share a full test path","description":"MEASURED 2026-09-21 (PR #88). Re-verified latent: 1444 names, zero duplicate full paths, zero duplicate (crate, path) pairs.\n\nTHE PROPOSED FIX IS DECLINED ON TWO INDEPENDENT GROUNDS, and the second is the interesting one:\n\n1. COST: 45.2s against 2.4s for the one workspace call (11 members, warm) — 18x. And --check-evidence needs the --ignored twin, so the honest figure is 22 cargo invocations.\n\n2. IT DOES NOT CLOSE THE CLASS IT NAMES. A package is not a binary. The improvise crate alone has FIVE integration test binaries beside its lib, and an integration test's top-level fn lists with NO MODULE PATH AT ALL — 60 of 1444 names are bare. So '-p improvise' prefixes all six identically and tests/a.rs::foo still cannot be told from tests/b.rs::foo.\n\nAny real fix is per-TARGET (--lib, --test \u003cname\u003e), not per-package. Whoever takes this should start there.\n\nWorth noting how this was found: the agent set out to time the per-package fix and discovered it does not work while reading the target list. The cost measurement was the excuse for reading it; the correctness finding was the payoff — an argument for measuring declines rather than asserting them.","status":"open","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:49:16Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:23:48Z","dependencies":[{"issue_id":"improvise-sxsf","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:49Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8shu","title":"Constrain the .improv grammar generator's strategy; improvise-io's lib binary is the run's outlier","description":"Split out of improvise-8ec3, whose second half (stop pinning one machine's number) is done. This is the first half, left open.\n\nimprovise-io's lib binary is an order of magnitude above every other binary in the run on every machine it has been measured on: 1.40-1.45s over three runs here (vs a ~0.06s median), 3.70-5.24s on the machine that filed 8ec3. The ratio is the portable signal; the seconds are not.\n\nIts slow half is the grammar-walking generator in persistence/parse.rs plus the .improv roundtrip proptests at the default 256 cases. A grammar walk with no size bound generates deeply nested documents whose cost is not uniform across cases, so a few cases dominate.\n\ndoctrine(proptest_cases) rules out the cheap fix: constrain the strategy, do NOT lower the count below 256.\n\nAcceptance: improvise-io's lib binary is no longer an order of magnitude above the run's median, with the case count still 256 and the properties still covering groups, formulas, views, hidden items and pipe-quoting edges. Report the before/after ratio, not seconds.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:14:01Z","created_by":"Edward Langley","updated_at":"2026-09-21T05:15:42Z","closed_at":"2026-09-21T05:15:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9v0j","title":"view_frame and view_shape are separated by example, not by property","description":"RE-SCOPED 2026-09-21. The ticket proposed a property test asserting that view_frame and view_shape agree — roughly \"every reframe is also a reshape\". THAT PROPERTY IS FALSE and the test would have failed. Measured by the Wave I agent and confirmed by the adversarial review of PR #92, with a prune_empty view of two rows and a ClearCell on North's only cell, driven through sessions.apply_caught_up:\n\n rows 2 -\u003e 1, layout_version 0 -\u003e 0, cursor_epoch 0 -\u003e 1\n\nA genuine reframe with no reshape. That is improvise-mcq4 working as designed: a cell write changes DATA, not the view's CONFIGURATION, and view_shape hashes configuration. Writing the proposed test would have been wrong.\n\nSo the original acceptance criterion is withdrawn. What remains is the real question the ticket was reaching for:\n\n## Acceptance Criteria (replaced)\n- State, in context/code/command.md, the PROPERTY that actually separates the two: view_shape answers \"has this view's configuration changed\" and view_frame answers \"does this view draw differently\". A data write moves the second and not the first, by design.\n- If a property test is wanted, it must be the true one — e.g. a configuration change moves BOTH, and a pure data write moves only the frame — with the direction asserted rather than the conjunction.\n- The consequence for ADR 001's middle tier is filed separately as improvise-6nsd (P4), with the severity note: a client names a cell by RecordHash, a coordinate surrogate, so a renumbered row does not misaddress a write, and cursor_epoch did move so every client is told.\n\nNote the file ownership that blocked the original attempt: view_frame, view_shape, view_frames and view_shapes are all private in crates/improvise-command/src/ui/app.rs.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:34Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T17:09:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9v0j","title":"view_frame and view_shape are separated by example, not by property","description":"RE-SCOPED 2026-09-21. The ticket proposed a property test asserting that view_frame and view_shape agree — roughly \"every reframe is also a reshape\". THAT PROPERTY IS FALSE and the test would have failed. Measured by the Wave I agent and confirmed by the adversarial review of PR #92, with a prune_empty view of two rows and a ClearCell on North's only cell, driven through sessions.apply_caught_up:\n\n rows 2 -\u003e 1, layout_version 0 -\u003e 0, cursor_epoch 0 -\u003e 1\n\nA genuine reframe with no reshape. That is improvise-mcq4 working as designed: a cell write changes DATA, not the view's CONFIGURATION, and view_shape hashes configuration. Writing the proposed test would have been wrong.\n\nSo the original acceptance criterion is withdrawn. What remains is the real question the ticket was reaching for:\n\n## Acceptance Criteria (replaced)\n- State, in context/code/command.md, the PROPERTY that actually separates the two: view_shape answers \"has this view's configuration changed\" and view_frame answers \"does this view draw differently\". A data write moves the second and not the first, by design.\n- If a property test is wanted, it must be the true one — e.g. a configuration change moves BOTH, and a pure data write moves only the frame — with the direction asserted rather than the conjunction.\n- The consequence for ADR 001's middle tier is filed separately as improvise-6nsd (P4), with the severity note: a client names a cell by RecordHash, a coordinate surrogate, so a renumbered row does not misaddress a write, and cursor_epoch did move so every client is told.\n\nNote the file ownership that blocked the original attempt: view_frame, view_shape, view_frames and view_shapes are all private in crates/improvise-command/src/ui/app.rs.\n","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-21T04:01:34Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T17:09:51Z","dependencies":[{"issue_id":"improvise-9v0j","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:07Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-h0iv","title":"group_runs coalesces by name alone, so two runs under different headers merge","description":"Found by the adversarial review of PR #77, which landed `AxisFrame::group_runs`\nas the one home of column-group run coalescing (improvise-8qlr).\n\n`group_runs` coalesces adjacent positions by group **name**:\n\n Some((prev, span)) if prev.map(|p| p.name.as_str()) == name =\u003e *span += 1,\n\nSo two runs under two DIFFERENT headers that happen to share a name read as one\nrun, and the browser draws one `\u003cth colspan\u003e` across both. `FrameGroup` carries\n`cat` as well as `name` — precisely so a client never has to work out which\ncategory a label came from — and comparing the pair would fix it.\n\nWHY PR #77 WAS RIGHT NOT TO FIX IT, so this is a decision and not a\nrediscovery: that PR's job was to make one rule out of two. Both prior copies\n— `src/ui/grid.rs`'s `group_name != prev_group` and\n`improvise-wasm-client/src/render.rs::col_groups`' `run.label == label` —\ncoalesced by name alone, so name-only is what the consolidation had to preserve\nto stay a consolidation. Changing the rule in the same commit that moves it\nwould have meant the agreement test could no longer tell \"the two ends agree\"\nfrom \"the two ends changed together\", which is the whole evidence of the move.\n\nTHE RISK OF LEAVING IT IS SPECIFIC AND NEW: a single home reads as decided.\nThe next person to look at `group_runs` finds one rule, one call site per front\nend, and a test pinning both — and has no reason to suspect the comparison is\nnarrower than the data it is given. That is the cost of a good consolidation\nand the reason to file rather than to note.\n\nReachability today: two categories on one axis, where the inner category has\ngroups of the same name under two different outer items — e.g. `Month` grouped\ninto `Q1` nested under `Region`, giving `North/Q1` and `South/Q1` adjacent. The\nexisting fixture in `tests/grid_frame_agreement.rs` has exactly this shape but\nis saved by the ungrouped `Sep` sitting between the two `Q1` runs, which is\nalso what makes improvise-gd83's spans come out as\n`[(Q1,2), (None,1), (Q1,2), (None,1)]` rather than merged. Remove `Sep` and the\ntwo runs merge.\n\nNot obviously a bug in every case: two adjacent runs of the same group name may\nwell be one visual span a user WANTS spanned. That is the question to answer\nfirst, and it is a question about what a group header means across a nesting\nboundary — which is why this is filed rather than fixed.\n\n## Steps to Reproduce\n\n1. Build a two-level column axis whose inner category is grouped and whose\n groups repeat under each outer item, with NO ungrouped item between them.\n2. `AxisFrame::group_runs` returns one run spanning both.\n3. Both front ends draw one group header across the boundary, because both now\n ask the same (name-only) rule.\n\n## Acceptance Criteria\n\nDecide whether two adjacent same-named runs under different headers are one\nspan or two, and say why in the commit. If two, `group_runs` compares\n`(cat, name)` and the agreement-test fixture gains a case with no separating\nungrouped item — the current one cannot see this.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:00:18Z","created_by":"agent-render-frame","updated_at":"2026-09-21T12:39:37Z","closed_at":"2026-09-21T12:39:37Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1wl0","title":"AxisFrame::first is serde(default), so a frame with no origin claims it leads the axis","description":"Found by the adversarial review of PR #77, which landed `AxisFrame::first`.\n\n`#[serde(default)]` on `first` forges the PERMISSIVE claim. `first: 0` is not\nan absence — it is the assertion \"this frame LEADS the axis\", and that is\nexactly what `body_from` acts on:\n\n match self.first + idx == 0 { true =\u003e \u0026self.entries[..], ... }\n\nSo an older peer's frame, or any frame whose `first` is dropped in transit,\ndeserializes as leading the axis. A scrolled browser then draws the carried\nheader ABOVE its first row — the header `body_from` exists to discard there,\nand the mirror image of the improvise-0o6x bug the field was added to fix.\n\nThis is the same argument that gave `Basis` no `Default` and no public\nconstructor (gotcha 43): a zero-valued claim that happens to be TRUE in the\ncommon case is a forgeable bypass rather than an absence. `first` is worse in\none respect — a `Basis` at least has to be constructed deliberately, while a\nserde default is applied silently by a missing field.\n\nNO SKEW SHIPS TODAY. Both ends are built from one workspace at one commit, so\nthere is no older peer. This is about the day there is one, and about the\nattribute reading as a considered compatibility decision when it was not.\n\nOptions, not yet chosen:\n\n * Drop the attribute, so a frame missing `first` fails to deserialize. Loud,\n and correct if a frame is never partially constructed. Note gotcha 37's\n observation that `#[serde(default)]` on an Option is documentation and not\n mechanism — here it is the opposite, it IS the mechanism.\n * Make the leading-ness unrepresentable-otherwise: `Option\u003cusize\u003e`, or a\n two-variant enum (`LeadsAxis` / `StartsAt(usize)`), so \"unknown\" cannot\n silently mean \"leads\".\n\nWhichever, say which in the commit, because the reason to prefer one is about\nwire compatibility and not about the type.\n\n## Steps to Reproduce\n\n1. Serialize an `AxisFrame` built for a scrolled window (`first \u003e 0`) and\n remove the `first` key from the JSON — the shape an older peer sends.\n2. Deserialize it. `first` is 0.\n3. `body_from(0)` now takes the `first + idx == 0` arm and returns the whole\n entry list, including the carried group header.\n4. The browser draws a group header above its first row, at every scroll\n position. Nothing reports anything.\n","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T04:00:14Z","created_by":"agent-render-frame","updated_at":"2026-09-21T12:39:36Z","closed_at":"2026-09-21T12:39:36Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gfvj","title":"A pure record reorder invalidates no cursor, and I could not build a fixture that reorders","description":"Left open by improvise-ni6k, which closed the rest of its class by observing the grid frame.\n\nni6k names SortData as its SHARPEST case: it changes neither the row count nor any label, and every position still names a different record. The frame fingerprint ni6k landed (App::view_frame, hashing GridLayout row_items/col_items) CANNOT see it, because records-mode row_items are positional (gotcha 1) -- they are 0,1,2... whatever order the store is in.\n\nI wrote the mechanism -- hashing layout.records[].coords alongside the labels -- and then REMOVED it before committing, because I could not demonstrate it catches anything. Every fixture I built came back in key order whatever order the cells were inserted in:\n\n wb.add_item(Region, North); wb.add_item(Region, East);\n set_cell(North/Revenue); set_cell(East/Revenue);\n -\u003e records mode row order is [East, North] BEFORE any SortData\n\nSo either SortData is a no-op for that shape, or something between DataStore::live_records / matching_cells / RecordRow::group is ordering records that gotcha 18 says arrive in insertion order. I did not chase which, and I am not asserting a bug in gotcha 18 on this evidence -- matching_positions sorts POSITIONS (which preserves insertion order), so the explanation is probably elsewhere and probably boring.\n\nTWO THINGS TO DO, in order:\n1. Find out what actually orders records-mode rows, and whether SortData can reorder them at all. If it cannot, this ticket closes as 'not a case' and ni6k's note should be corrected -- its sharpest example would be hypothetical.\n2. Only if it can: put the coords back into view_frame and pin it with the fixture that reproduces.\n\nWHY IT WAS NOT SHIPPED UNVERIFIED: a confident comment over a mechanism nobody has seen fire is this repo's named defect. See doctrine(check_the_checker) and the wave brief's 'a green suite is not evidence'.","notes":"REPRODUCED AND FIXED. My original conclusion was WRONG, and the way it was wrong is the useful part.\n\nI claimed I could not build a fixture where SortData reorders records. The reviewer of PR #78 reproduced it in thirty lines. I re-derived it: insert West/North/East, call sort_by_key, and both live_records() and the records-mode GridLayout reorder to East/North/West.\n\nWHY MY FIXTURE HID IT -- not the reviewer's guess (insertion already sorted), and better than that: MY FIXTURE ENTERED RECORDS MODE BY PRESSING R, and ToggleRecordsMode (command/cmd/grid.rs:476) pushes effect::SortData AS ITS FIRST EFFECT. So the store was sorted by the act of entering records mode, and the SortData I then applied was a genuine no-op. My two-record store did reorder at the DataStore level -- I checked after the review -- so the fixture, not the store, was the problem.\n\nTHE GENERAL TRAP, worth carrying: a records-mode fixture built through the key path has an already-sorted store by construction, so it cannot demonstrate anything order-sensitive. Build one by setting _Index/_Dim axes directly. That is now written into context/test-evidence/command.md under sorting_the_store_in_records_mode_invalidates_the_cursor.\n\nTHE FIX: the layout.records coords hash I removed is restored in App::view_frame, and sorting_the_store_in_records_mode_invalidates_the_cursor pins it -- asserting the row COUNT and the LABELS are both unchanged, so it is the sharp case and not a shape change wearing its name. Proved by removing the coords hash again: that test fails and NOTHING ELSE IN THE CRATE does.\n\nMY PROCESS ERROR, for the record: I concluded 'cannot reproduce' from ONE fixture without varying it, and then filed that conclusion as a finding. Removing the unverified mechanism was right; concluding the hole was not real was not. 'I could not reproduce it' and 'it does not reproduce' are different claims and I filed the second having only earned the first.\n\nClosing.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T03:47:15Z","created_by":"claude-agent-protocol","updated_at":"2026-09-21T04:01:44Z","closed_at":"2026-09-21T04:01:44Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -634,10 +769,10 @@ {"_type":"issue","id":"improvise-4ktt","title":"The js job's bundle-freedom is a convention, not a gate","description":"Found by adversarial review of PR #61, 2026-09-20 (finding 6).\n\nPR #61 states a constraint as load-bearing: the `js` CI job must never need a\nbuilt wasm bundle, because the moment it does it inherits the browser job's\nthree wasm-pack builds and its whole value — seconds instead of ~4m30s —\nevaporates. context/code/clients.md says so in prose.\n\nThe MECHANISM is a shell glob and a directory convention:\n\n js job: node --test web/tests/*.test.js\n browser job: node --test web/tests/bundle/*.test.js\n\nNothing stops someone adding `readFileSync('../pkg/…')` to a file directly in\nweb/tests/. It would pass locally (the bundle is usually built) and fail in the\n`js` job with a confusing ENOENT — or, worse, be \"fixed\" by adding a build step\nto the job, which silently spends the four minutes the split exists to avoid.\n\nThis is the shape the repo already dislikes: a rule stated in prose with no\ngate, where the failure direction looks like ordinary breakage.\n\nPOSSIBLE FIXES, cheapest first:\n1. A node test in web/tests/ that reads its own sibling files and asserts none\n of them mentions `pkg/` or `worker-pkg/` — the same static shape as\n render_surface.test.js, which reads app.js and index.html.\n2. Run the `js` job in a container/step with web/pkg absent, so the dependency\n is impossible rather than discouraged. Costs a clean checkout, which the job\n already has: `web/pkg` is gitignored, so in CI it is ALREADY absent — which\n means (2) is really \"assert it stays that way\", i.e. (1).\n\nNote the constraint is currently satisfied and is checked by nothing.\n","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T20:53:55Z","created_by":"wave3-jstests","updated_at":"2026-09-21T04:49:30Z","closed_at":"2026-09-21T04:49:30Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-r0qa","title":"The evidence checker validates only heading-to-test, never test-to-evidence","description":"Found by adversarial review of PR #59, 2026-09-20, as the first case to walk into the gap.\n\nscripts/check-comment-runs.py --check-evidence validates ONE direction: every '### name' heading in context/test-evidence/ must name a test that exists. design-principles §7 states that choice deliberately — 'a test may have no evidence (most do not), but evidence naming a test nobody can find is a dangling explanation.'\n\nThe blind spot is the reverse. PR #59 added a Rust test whose doc comment says 'see context/test-evidence/clients.md', and that file has NO entry for the_drill_key_and_an_aggregate_edit_refuse_for_different_reasons. The checker passed (503 headings, all resolving). So the new assertion's proof lived only in the PR body — which is not in the repo, and which design-principles §6 now says is exactly where an argument must not live, because nothing can audit it.\n\nWHY THE ORIGINAL ASYMMETRY IS STILL RIGHT, and why this is a narrower fix than 'check both ways': most tests have no evidence and should not. The check is not 'every test has evidence' — it is 'a test that CITES evidence cites evidence that exists'. That is a different predicate and it has no false positives: a doc comment naming context/test-evidence/\u003cfile\u003e.md (and optionally a heading) is a claim, and the claim is checkable.\n\nNote the failure mode it closes is the one this repo keeps finding: a pointer that reads as a promise the thing exists. Same argument as a dead ticket ID, in another key.\n\n## Steps to Reproduce\n\n1. Add a Rust test whose doc comment cites context/test-evidence/clients.md for a section that does not exist.\n2. python3 scripts/check-comment-runs.py --check-evidence\n3. It passes.","acceptance_criteria":"A test whose doc comment cites context/test-evidence/ for a section that does not exist fails the checker. Demonstrated by pointing a test's doc comment at an absent heading and watching --check-evidence fail.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T20:39:27Z","created_by":"orchestrator","updated_at":"2026-09-21T04:49:28Z","closed_at":"2026-09-21T04:49:28Z","comments":[{"id":"503ea75f-8d90-5bdd-bb0e-fa3aa18b0e61","issue_id":"improvise-r0qa","author":"wave3-jstests","text":"A second, sharper instance of the same blind spot, found by adversarial review of PR #61 (2026-09-20). --check-evidence resolves only `### ` headings. context/test-evidence/clients.md uses `#### \"quoted test name\"` for the Playwright specs, which is one `#` deeper — so the checker cannot see them at all. PR #61 deleted the test named at clients.md:547 and the gate stayed green while the heading became exactly the dangling explanation design-principles §7 describes: it read as a promise that a guard existed, for a property that had in fact been lost (the PR #21 close-routing half, lost for the third time). The heading is accurate again only because the follow-up restored the test under its exact original name. Whatever fix this ticket takes should normalise the heading depth or accept both levels; the `####` form is used deliberately for JS test names, which cannot be `### ` because the checker would then demand a Rust `fn` of that name.\n","created_at":"2026-09-20T20:54:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-0x9u","title":"A click cannot move the grid selection while an editor is open","description":"Found by adversarial review of the unreviewed pointer work (959f249), demonstrated in Chromium.\n\nweb/app.js:333-339 (cell click) plus edit()'s 'if (editing) return;' (~:354) and its blur -\u003e close(false) -\u003e repaint().\n\nDemonstrated: click cell 0 (editor opens, input present) -\u003e click cell 4 -\u003e 0 inputs, selection STILL AT INDEX 0. The mousedown on cell 4 blurs the input, close(false) calls repaint(), el.grid.replaceChildren(..) destroys the \u003ctd\u003e that received the mousedown, so no click event ever fires and click_cell is never called. The pointer is dead for that interaction; the user must Escape or click outside the grid first.\n\nSo the property the new spec names — 'a click moves the selection, not only the editor' — DOES NOT HOLD from an editing state, and pointer.spec.js:112 cannot see it because it presses Escape before asserting. That is the repo's characteristic find: a test that passes while the thing it names is broken in a neighbouring state.\n\nPre-existing in the edit()/repaint interaction rather than introduced by 959f249 — but now LOAD-BEARING, because the click is advertised as a selection move.\n\nRepair: defer the teardown (queueMicrotask/setTimeout(0) around repaint() in close), or use mousedown rather than click for the cell handler so it fires before the blur tears the table down. The regression test belongs in harness/tests/pointer.spec.js per gotcha 48.\n\n## Steps to Reproduce\n\n1. Load /app/index.html, click an editable cell — an editor opens.\n2. Without pressing Escape, click a different editable cell.\n3. The editor closes and the selection does not move.","acceptance_criteria":"Clicking a second cell while an editor is open moves the selection to it. A harness assertion in pointer.spec.js covers the editing state specifically, not only the post-Escape state.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T20:18:11Z","created_by":"orchestrator","updated_at":"2026-09-21T04:43:14Z","started_at":"2026-09-21T03:28:12Z","closed_at":"2026-09-21T04:43:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} +{"_type":"issue","id":"improvise-h04z","title":"Decide whether UI glyphs move to the protocol, as short_label() did","description":"web/app.js's indicator() carries the comment 'only this spelling is the page's (gotcha 26)', so 'the fact is shared, the glyph is the page's' is a DOCUMENTED decision, not an oversight. This ticket asks whether that decision is still right. It is filed rather than answered because overturning something written down deliberately is a product call.\n\nEVIDENCE, which is observed rather than argued. The convention has already failed three times inside the code that states it:\n\n1. web/app.js categoryList inlined U+25B8/U+25BE (SMALL triangles) and did not call indicator(), which returns U+25B6/U+25BC. So the browser's category panel disagreed with src/ui/category_panel.rs:78 AND with the browser's own grid group headers. Fixed on PR #55 (323677b) by calling indicator().\n2. harness/tests/widgets.spec.js asserted the divergent pair, so the obvious cure would have failed a test. Fixed in the same commit.\n3. harness/tests/pointer.spec.js had INDEPENDENTLY grown the same wrong assertions on a different branch. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, and this is it observed with data rather than argued from doctrine.\n\nStill outstanding after those fixes: web/app.js viewRow spells the active-view marker \"▶ \" as a literal, duplicating src/ui/view_panel.rs:62. The two AGREE today (both U+25B6), so it is the hazard rather than the divergence — which is exactly the state categoryList was in before it drifted.\n\nTHE DECISION. Either (a) glyphs move to the shared layer the way Axis::short_label()/initial()/glyph() already did, making the TUI and the browser draw one spelling by construction; or (b) the per-page convention is kept and the comment is corrected to say 'one spelling PER PAGE, which may differ from the TUI', which is what it actually means today. What must not persist is the current state, where the comment says 'only this spelling is the page's' (singular) while nothing enforces it.\n\nNote (a) is not free: a glyph is presentation, and design-principles §5 explicitly lists 'the mapping from a semantic value to a framework type' as what legitimately stays per-framework (AxisColor -\u003e ratatui::Color). The counter-argument is that a terminal and a DOM can both render U+25B6, so unlike a Color there is nothing to translate — which is why this is a decision and not an obvious cleanup.\n\nFound by the review agent on PR #55.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T19:50:18Z","created_by":"wave3-webui","updated_at":"2026-09-20T19:50:18Z","dependencies":[{"issue_id":"improvise-h04z","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:33Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"246755fe-5911-5924-96d1-9f3403529bf8","issue_id":"improvise-h04z","author":"wave3-webui","text":"COUNTER-ARGUMENT, recorded before anyone decides — because the evidence in the\ndescription supports two readings and the incident makes only one of them salient.\n\nTHE READING THE DESCRIPTION IMPLIES: the convention is wrong, so glyphs should\nmove to the shared layer as Axis::short_label()/initial()/glyph() did.\n\nTHE OTHER READING, which fits the same evidence: the convention is RIGHT and was\nsimply not followed, because nothing enforced it. Gotcha 26's pattern is that the\n*fact* is shared and the *rendering* is local — exactly what AxisColor -\u003e\nratatui::Color does — and a third front end would rightly pick its own glyph. On\nthis reading the fix is to enforce one-home-per-front-end and to correct\nindicator()'s comment, which claims a singular spelling that nothing checks.\n\nWHAT DECIDES BETWEEN THEM, and it is a fact rather than a preference:\n`web/app.js indicator()` and `src/ui/category_panel.rs:78` ALREADY AGREED —\nU+25B6/U+25BC in both. Fixing `categoryList` to call `indicator()` made the two\nfront ends agree with NO shared-layer change at all. That is direct evidence the\nconvention was sufficient and merely unenforced.\n\nI OVERSTATED THE EVIDENCE AND WANT THAT ON THE RECORD. I wrote that three files\nconverging \"answers 'is one duplicated literal worth a protocol change' with data\ninstead of doctrine.\" It does not. The three are not three independent\nderivations:\n\n 1. categoryList BYPASSED indicator(), the one home the page already had.\n 2. widgets.spec.js asserted the page's resulting (wrong) output.\n 3. pointer.spec.js asserted the same output, on a later branch.\n\nOnly (1) chose a glyph. (2) and (3) are downstream of it — tests pinning an\nobserved rendering, which is the \"a test cements the wrong state\" failure and is\nserious, but is NOT evidence about where glyphs should live. So the three answer\n\"was the page's own one-home rule enforced?\" (no), not \"is a protocol change\nwarranted?\" — a different question from the one I claimed.\n\nTHE ONE PIECE OF EVIDENCE THAT DOES SURVIVE FOR THE PROTOCOL READING:\n`web/app.js viewRow` spells the active-view marker \"▶ \" as a literal duplicating\nsrc/ui/view_panel.rs:62. That one is NOT a bypass — there is no browser-side\nmarker helper to bypass — so it is a genuine second spelling of a fact the TUI\nalso spells, and it is the only true cross-front-end duplication left after the\ncategoryList fix. A decision should turn on that case, not on the three.\n\nWHAT MUST NOT PERSIST EITHER WAY: indicator()'s comment says \"only this spelling\nis the page's\" (singular) while nothing enforces it — which is how the bypass\nwent unnoticed. Whichever reading wins, that comment is currently false.\n\nRecorded at the orchestrator's request so the decision is made against both\nreadings. Not resolved here.\n","created_at":"2026-09-20T20:10:52Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-wsaw","title":"The browser ignores category_panel_open, so C toggles nothing visible","description":"RenderModel::category_panel_open has existed since the panel was added and web/app.js has never read it: repaint() calls categoryList(model) unconditionally, so #categories is permanently visible. Verified in Chromium: on a fresh load the client's own view slice says category_panel_open:false while the sidebar is drawn.\n\nConsequences. The TUI draws the category panel only when the flag is set (src/draw.rs:322), so the two front ends disagree about what the panel toggle does. In the browser, Shift-C and Ctrl-C change the mode label and nothing else - the user gets no feedback that the panel opened, which is part of why 'I can't expand categories' reads as broken.\n\nThe DECISION: honour the flag (matching the TUI, but the sidebar then disappears on load and the user must press C to see any category), or keep the always-visible sidebar and treat the flag as not applying to this front end (in which case app.js should stop carrying a field it ignores, and the browser's C should do something else visible - focus styling on the panel, say).\n\nNote fix/web-ui-correctness added #views and #formulas and DOES honour their flags, so the three panels are currently inconsistent with each other. That inconsistency is the concrete cost of leaving this open.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave3-webui","owner":"el-github@elangley.org","created_at":"2026-09-20T19:17:16Z","created_by":"wave3-webui","updated_at":"2026-09-21T04:43:23Z","started_at":"2026-09-20T19:30:59Z","closed_at":"2026-09-21T04:43:23Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-8ec3","title":"improvise-io's test binary breaches the 2s per-binary rule on some machines, and the pinned figure is not reproducible","description":"`doctrine(suite_speed)` and design-principles §7 both state the rule that bites\nas **no single test binary over 2 seconds**, and pin it at `22d43b2` with\n\"longest single binary 1.42s\".\n\nThat number is not reproducible across machines. Measured on PR #51's branch:\n\n- This agent's machine: `improvise_io` lib, 1.42-1.68s over four runs.\n- The reviewing agent's machine, same branch: **3.70-5.24s**, and **4.82s on\n `main`** — so the breach is pre-existing, not introduced by #51.\n\nTwo things follow, and the second is the one worth doing.\n\n**The stated number is a measurement of one machine presented as a property of\nthe suite.** `doctrine(suite_speed)` already says \"re-MEASURE rather than\nre-assert\", and this is what that costs when the measurement is 3x apart on two\nmachines: a PR body claiming \"longest single binary 1.47s\" is checkable and\nfalse for the reader who checks it, which is worse than no claim.\n\n**`improvise-io` is the binary at issue, and it is proptest-dominated.** Its\nslow half is the grammar-walking generator in `persistence/parse.rs` plus the\n`.improv` roundtrip proptests, at the default 256 cases.\n`doctrine(proptest_cases)` says constrain the strategy rather than lower the\ncount, so the work is to look at what the generator actually produces — a\ngrammar walk with no size bound generates deeply nested documents whose cost is\nnot uniform across cases.\n\n## Steps to Reproduce\n\n1. `cargo test -p improvise-io --lib` and read the `finished in` it prints.\n2. Compare against the \"1.42s\" pinned in `context/design-principles.md` §7 and\n in `doctrine(suite_speed)`.\n3. On a loaded or slower machine the figure exceeds the stated 2-second\n per-binary limit, on `main` as well as on any branch.\n","acceptance_criteria":"Either improvise-io's lib binary is brought under 2s on the slow measurement (by constraining the .improv grammar generator's strategy, NOT by lowering proptest cases below 256 — doctrine(proptest_cases)), or design-principles §7 and doctrine(suite_speed) stop pinning a single machine's number and say what the rule is measured against. A figure that is 3x apart on two machines is not checkable, and doctrine(suite_speed) already says re-measure rather than re-assert.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:53:08Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T04:49:32Z","closed_at":"2026-09-21T04:49:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold's cost, measured","description":"RESOLVED on the PR that raised it (#54). Kept open only to record the numbers\nand the one remaining, declined, optimisation.\n\n## What it was\n\nThe stall was **not** the fold itself. It was the fold running through\n`eval_expr_cached`: at a member-record key, `Expr::Ref` looks the value up in\n`formula_cache`, which is keyed by stems with `none_cats` STRIPPED, so it could\nnever hit — and fell through to `aggregate_raw`, one poorly-narrowed scan PER\nMEMBER. An O(n^2) shape, which is why it was superlinear.\n\nThat is the same defect as the review's finding 1 (the two evaluators\ndisagreeing). Fixing the divergence — both evaluators hand the fold the same\nrecord-level `eval_expr` — fixed the cost. One defect, two symptoms.\n\n## Measured\n\n`crates/improvise-core/examples/fold_bench.rs`, five runs per point, best-worst,\none routing formula `Pred = SUM(IF(Cost \u003c 50, 1, 10))` with Tag on Axis::None.\n\n cells routing (before) routing (after) linear Rev - Cost\n 400 0.6-0.9 ms 0.4-0.6 ms 0.2-0.2 ms\n 2000 3.1-4.3 ms 2.0-2.1 ms 0.8-0.9 ms\n 18050 51.1-53.3 ms 17.9-18.7 ms 7.6-8.3 ms\n\nDebug at 18,050 cells: ~133 ms after, against ~72 ms linear.\n\nBefore: 45x the cells cost 85x the time (superlinear).\nAfter: 45x the cells cost 45x the time (linear), at 2.3x a linear formula.\n\n## \"18,050 cells\" was not a sufficient description of the old workload\n\nThe strongest evidence for the diagnosis, measured by the PR's reviewer on the\nmerged tree and recorded here because it is the part that actually pins the\ncause. At the SAME 18,050 cells, the old code cost 51 ms at a 25x361 shape and\n244 ms at 95x95 — a 4.8x spread from group COUNT alone, which is what a\nper-member `aggregate_raw` scan predicts and a constant factor does not. After\nthe fix those two shapes are 16.9-17.2 ms and 16.4-17.5 ms: indistinguishable.\n\n**The shape-dependence itself is gone**, and that is the confirmation. Scaling\nre-checked linear out to 72,200 cells (4x the cells, 4x the time, 17 -\u003e 68 ms).\nIndependently: a `SUM(Rev) - SUM(Cost)` baseline measures the same 7.1-7.8 ms as\nthe `Rev - Cost` one, so the 2.3x ratio is not an artefact of comparing against\na cheaper formula.\n\nAnyone re-measuring this must state the region/tag shape, not just the cell\ncount.\n\n## The remaining optimisation, and why it is declined\n\n`recompute_formulas` runs exactly TWO passes (measured, by counting, not\nassumed): one to compute and one to confirm stability. Raw data does not change\nwithin a recompute, so the member set per `partial` is stable across those two\npasses and could be computed once. That caps the win at ~2x on the fold's share\nof an already-linear 18 ms.\n\nDeclined as not worth it: a memo is state, and a memo keyed by anything less\nthan the whole `partial` is a second answer to \"what does this cell depend on\",\nwhich is the disease this repo names. Reopen with a document that makes 18 ms\nhurt.\n\n## What was considered and is wrong, so nobody re-proposes it\n\nRaised during the wave as a possible direction — NOT by the code review, which\ndid not propose it — and rejected on inspection:\n\n\"Fold only where folding changes the answer — if `inner` has no If and no Is,\naggregate-then-route equals route-then-aggregate\" does NOT hold. It fails for\nMIN/MAX over any non-trivial expression, and for any non-linear BinOp:\n`SUM(Rev * Cost)` is not `SUM(Rev) * SUM(Cost)`. Worse, the cheap path it would\nfall back to never computed aggregate-then-route at all — it summed the raw\nmatching cells and DISCARDED the expression. The only provably-equal case is the\nbare `Expr::Ref` fast path, which is already special-cased.\n\n## Termination, checked rather than assumed\n\nThe cached path's hand-off to `eval_expr` resets depth to MAX_EVAL_DEPTH at each\n`Agg`, while the uncached `Agg` threads its own `depth` through. That asymmetry\nis what keeps a chain bounded and must not be \"tidied\" into one rule. Verified\nterminating and cached/uncached-agreeing on: mutual reference, self-reference, a\n24-deep chain of folded formulas, nested SUM(SUM(..)), and a derived measure\ninside the fold.\n\n## Steps to Reproduce (the original stall)\n\n1. `direnv exec . cargo run --release -p improvise-core --example fold_bench`\n2. Before the fix, route the cached fold through `eval_expr_cached` rather than\n `eval_expr` (one line in `eval_formula_with_cache`'s `Expr::Agg` arm).\n3. The 18,050-cell row goes from ~18 ms to ~51 ms at a 25x361 shape, ~244 ms at\n 95x95, and the scaling goes superlinear.\n","acceptance_criteria":"A measured number for recompute_formulas over a routed formula at a realistic record count, beside the same document with a linear formula, written into context/code/core.md 13 next to the claim it replaces. If it is slow enough to matter, a decision recorded about what to do; if it is not, the measurement recorded so the next reader does not re-open it.","notes":"ATTRIBUTION CORRECTION 2026-09-20: the rejected 'no If and no Is =\u003e aggregate-then-route equals route-then-aggregate' optimisation was proposed by the ORCHESTRATOR, not by the adversarial reviewer. The reviewer asked for it to be on the record that it never proposed it; what it offered was 'either teach the fold's Ref to reach the cache, or document the restriction'. The rejection stands and the reasoning is the implementing agent's: the shortcut fails for MIN/MAX and for any non-linear BinOp (SUM(Rev*Cost) != SUM(Rev)*SUM(Cost)), and the cheap path it would fall back to never computed aggregate-then-route at all — it discarded the expression. The only provably-equal case is the bare Expr::Ref already special-cased.\n\nALSO from the re-review, and it is better evidence than the single-point improvement: the two benchmark fixtures did NOT measure the same thing before the fix and DO now. At 18,050 cells the author's 25x361 shape and the reviewer's 95x95 shape gave 51 ms and 244 ms pre-fix — a 4.8x spread AT EQUAL CELL COUNT, which is exactly what a per-member aggregate_raw scan predicts, since its cost grew with GROUP count rather than cell count. Post-fix the spread is gone (16.9-17.2 vs 16.4-17.5). The diagnosis is confirmed by the DISAPPEARANCE OF THE SHAPE-DEPENDENCE, not merely by a smaller number. So '18,050 cells' was never a sufficient description of the old workload; it is now. Scaling verified linear to 72,200 cells (4x cells, 4x time).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:51:21Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:31:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9iyv","title":"The route-then-aggregate fold's cost, measured","description":"RESOLVED on the PR that raised it (#54). Kept open only to record the numbers\nand the one remaining, declined, optimisation.\n\n## What it was\n\nThe stall was **not** the fold itself. It was the fold running through\n`eval_expr_cached`: at a member-record key, `Expr::Ref` looks the value up in\n`formula_cache`, which is keyed by stems with `none_cats` STRIPPED, so it could\nnever hit — and fell through to `aggregate_raw`, one poorly-narrowed scan PER\nMEMBER. An O(n^2) shape, which is why it was superlinear.\n\nThat is the same defect as the review's finding 1 (the two evaluators\ndisagreeing). Fixing the divergence — both evaluators hand the fold the same\nrecord-level `eval_expr` — fixed the cost. One defect, two symptoms.\n\n## Measured\n\n`crates/improvise-core/examples/fold_bench.rs`, five runs per point, best-worst,\none routing formula `Pred = SUM(IF(Cost \u003c 50, 1, 10))` with Tag on Axis::None.\n\n cells routing (before) routing (after) linear Rev - Cost\n 400 0.6-0.9 ms 0.4-0.6 ms 0.2-0.2 ms\n 2000 3.1-4.3 ms 2.0-2.1 ms 0.8-0.9 ms\n 18050 51.1-53.3 ms 17.9-18.7 ms 7.6-8.3 ms\n\nDebug at 18,050 cells: ~133 ms after, against ~72 ms linear.\n\nBefore: 45x the cells cost 85x the time (superlinear).\nAfter: 45x the cells cost 45x the time (linear), at 2.3x a linear formula.\n\n## \"18,050 cells\" was not a sufficient description of the old workload\n\nThe strongest evidence for the diagnosis, measured by the PR's reviewer on the\nmerged tree and recorded here because it is the part that actually pins the\ncause. At the SAME 18,050 cells, the old code cost 51 ms at a 25x361 shape and\n244 ms at 95x95 — a 4.8x spread from group COUNT alone, which is what a\nper-member `aggregate_raw` scan predicts and a constant factor does not. After\nthe fix those two shapes are 16.9-17.2 ms and 16.4-17.5 ms: indistinguishable.\n\n**The shape-dependence itself is gone**, and that is the confirmation. Scaling\nre-checked linear out to 72,200 cells (4x the cells, 4x the time, 17 -\u003e 68 ms).\nIndependently: a `SUM(Rev) - SUM(Cost)` baseline measures the same 7.1-7.8 ms as\nthe `Rev - Cost` one, so the 2.3x ratio is not an artefact of comparing against\na cheaper formula.\n\nAnyone re-measuring this must state the region/tag shape, not just the cell\ncount.\n\n## The remaining optimisation, and why it is declined\n\n`recompute_formulas` runs exactly TWO passes (measured, by counting, not\nassumed): one to compute and one to confirm stability. Raw data does not change\nwithin a recompute, so the member set per `partial` is stable across those two\npasses and could be computed once. That caps the win at ~2x on the fold's share\nof an already-linear 18 ms.\n\nDeclined as not worth it: a memo is state, and a memo keyed by anything less\nthan the whole `partial` is a second answer to \"what does this cell depend on\",\nwhich is the disease this repo names. Reopen with a document that makes 18 ms\nhurt.\n\n## What was considered and is wrong, so nobody re-proposes it\n\nRaised during the wave as a possible direction — NOT by the code review, which\ndid not propose it — and rejected on inspection:\n\n\"Fold only where folding changes the answer — if `inner` has no If and no Is,\naggregate-then-route equals route-then-aggregate\" does NOT hold. It fails for\nMIN/MAX over any non-trivial expression, and for any non-linear BinOp:\n`SUM(Rev * Cost)` is not `SUM(Rev) * SUM(Cost)`. Worse, the cheap path it would\nfall back to never computed aggregate-then-route at all — it summed the raw\nmatching cells and DISCARDED the expression. The only provably-equal case is the\nbare `Expr::Ref` fast path, which is already special-cased.\n\n## Termination, checked rather than assumed\n\nThe cached path's hand-off to `eval_expr` resets depth to MAX_EVAL_DEPTH at each\n`Agg`, while the uncached `Agg` threads its own `depth` through. That asymmetry\nis what keeps a chain bounded and must not be \"tidied\" into one rule. Verified\nterminating and cached/uncached-agreeing on: mutual reference, self-reference, a\n24-deep chain of folded formulas, nested SUM(SUM(..)), and a derived measure\ninside the fold.\n\n## Steps to Reproduce (the original stall)\n\n1. `direnv exec . cargo run --release -p improvise-core --example fold_bench`\n2. Before the fix, route the cached fold through `eval_expr_cached` rather than\n `eval_expr` (one line in `eval_formula_with_cache`'s `Expr::Agg` arm).\n3. The 18,050-cell row goes from ~18 ms to ~51 ms at a 25x361 shape, ~244 ms at\n 95x95, and the scaling goes superlinear.\n","acceptance_criteria":"A measured number for recompute_formulas over a routed formula at a realistic record count, beside the same document with a linear formula, written into context/code/core.md 13 next to the claim it replaces. If it is slow enough to matter, a decision recorded about what to do; if it is not, the measurement recorded so the next reader does not re-open it.","notes":"ATTRIBUTION CORRECTION 2026-09-20: the rejected 'no If and no Is =\u003e aggregate-then-route equals route-then-aggregate' optimisation was proposed by the ORCHESTRATOR, not by the adversarial reviewer. The reviewer asked for it to be on the record that it never proposed it; what it offered was 'either teach the fold's Ref to reach the cache, or document the restriction'. The rejection stands and the reasoning is the implementing agent's: the shortcut fails for MIN/MAX and for any non-linear BinOp (SUM(Rev*Cost) != SUM(Rev)*SUM(Cost)), and the cheap path it would fall back to never computed aggregate-then-route at all — it discarded the expression. The only provably-equal case is the bare Expr::Ref already special-cased.\n\nALSO from the re-review, and it is better evidence than the single-point improvement: the two benchmark fixtures did NOT measure the same thing before the fix and DO now. At 18,050 cells the author's 25x361 shape and the reviewer's 95x95 shape gave 51 ms and 244 ms pre-fix — a 4.8x spread AT EQUAL CELL COUNT, which is exactly what a per-member aggregate_raw scan predicts, since its cost grew with GROUP count rather than cell count. Post-fix the spread is gone (16.9-17.2 vs 16.4-17.5). The diagnosis is confirmed by the DISAPPEARANCE OF THE SHAPE-DEPENDENCE, not merely by a smaller number. So '18,050 cells' was never a sufficient description of the old workload; it is now. Scaling verified linear to 72,200 cells (4x cells, 4x time).","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:51:21Z","created_by":"wave3-4zl7","updated_at":"2026-09-20T19:31:00Z","dependencies":[{"issue_id":"improvise-9iyv","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wd6n","title":"A fit may still not split on col.missing or a date sub-feature","description":"improvise-4zl7 closed the categorical third of improvise-8xhy: a one-hot over a\nreal category is now emitted as `Cat IS Item`. Two artefact families remain\n`Spelling::Artefact` and are still refused by `fitted_formula`:\n\n- `col.missing`, the 0/1 indicator `preprocess` adds for a column with any\n missing cell. It means \"this record has no value for measure X\" — a question\n the formula language has no word for at all. `Expr::Ref` over an absent cell\n is an error, not a zero, so there is no existing spelling to reuse.\n- the date sub-features `when.epoch_day`, `when.year`, `when.month`,\n `when.day`, `when.dow`, and with a time component `when.hour`, `when.minute`.\n These need date arithmetic improvise does not have anywhere: a `.improv` cell\n holding a date is `CellValue::Text`, and nothing in `eval_expr` reads one.\n\nBoth are visible to a user only as a refusal naming the feature, which is the\nright interim. Closing either is a formula-language change, not an emitter\nchange, and they are independent of each other.\n\n## Steps to Reproduce\n\n1. Build a document whose only usable feature is a measure with some cells\n cleared, so `preprocess` emits `\u003cmeasure\u003e.missing`, and whose target is best\n explained by which records have that measure at all.\n2. `fit-measure \u003ctarget\u003e`.\n3. The champion splits on `\u003cmeasure\u003e.missing` and `fit_measure` refuses with\n \"the fitted tree splits on [\\\"\u003cmeasure\u003e.missing\\\"], which the formula\n language cannot spell at all\".\n\nThe date half reproduces the same way with a column of ISO dates: see\n`crates/gp-tree/src/data.rs::build_date` for the sub-features it emits, and\n`the_missing_flag_and_the_date_sub_features_are_refused_too` in\n`crates/improvise-gp/src/lib.rs` for the refusal asserted.\n","acceptance_criteria":"A fitted tree that splits on a col.missing flag or a date sub-feature is emitted as a formula that evaluates, or the family is declined in writing with the reason. Whichever way it goes, the two families are answered separately: they need different vocabulary. improvise-4zl7's route-then-aggregate rule (context/code/core.md 13) must keep holding for whatever is emitted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T18:50:51Z","created_by":"wave3-4zl7","updated_at":"2026-09-21T05:15:42Z","closed_at":"2026-09-21T05:15:42Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-cl7y","title":"A browser reports \"Formula added\" for a formula only the host can parse","description":"Found by the lockstep test improvise-e24v added for commit-buffer, once it\nwas widened to cover the Formula kind (the review's Finding 2).\n\nCommitBuffer emits its optimistic status FIRST and the add effect LAST, so on\nthe host a parse failure overwrites \"Formula added\" with \"Formula error: ...\"\n(improvise-6t3). The client mirrors the command line for line, which is\ncorrect -- but it cannot parse, so it folds \"Formula added\" and stops there.\nThe two ends then disagree on the status line, and the host's correction is a\nViewOnly effect that reaches nobody.\n\nThe user sees a browser confidently report a formula it did not add. This is\nthe same room improvise-fdt6 describes from the other side.\n\nNot a defect in the mirror: doctrine(mirror_the_cmd_line_for_line) says the\nclient reproduces its Cmd, and it does. The gap is that AddFormula's success\nis not observable to the composer. Pinned, not fixed, by\na_formula_the_client_cannot_parse_diverges_on_the_status_line, which asserts\nBOTH sides of the divergence so a fix has to come there and change it.\n\n## Steps to Reproduce\n1. Open the browser client and press F, then n, to open the formula prompt.\n2. Type an unparseable formula -- \"P=\" -- and press Enter.\n3. The status line reads \"Formula added\". No formula was added, and the host's\n \"Formula error\" never arrives.","acceptance_criteria":"A client that cannot parse does not claim success. Either the status\nfor a fallible add is withheld until the host answers (which needs the host to\naddress a status back to the composer, as ClientFrame::Save's rejection does),\nor the formula text is validated where both ends can do it. Whichever, the\ntest named above must change, because it currently asserts the divergence.","notes":"Checked during the adversarial review of PR #50, so nobody re-litigates it:\n\"the client cannot parse\" is true of the DEPENDENCY GRAPH, not merely of the\ncode as written. The repo map lists improvise-formula as a browser-bundle\ncrate, which reads as though parse_formula were a free call away -- it is not.\nimprovise-formula is a dependency of neither improvise-protocol nor\nimprovise-wasm-client; it reaches the bundle only through improvise-io and\nimprovise-command, neither of which a thin client has.\n\nSo closing this is a dependency decision (put improvise-formula on the thin\nclient's graph, and pay whatever that costs the bundle) rather than a call\nsomebody forgot to make. The other route -- have the host address a status\nback to the composer, the way ClientFrame::Save's rejection already does --\ncosts no dependency and is probably the cheaper answer.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:39Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T04:43:21Z","started_at":"2026-09-21T03:28:13Z","closed_at":"2026-09-21T04:43:21Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pubt","title":"[bug] display_value_in reads the host's drill edits against a foreign layout","description":"Found by the adversarial review of PR #48 (improvise-jytm).\n\n`project::cell_display` fills `edit_text` from `App::display_value_in(layout, key)`,\nand that function consults `App::drill_state` — the host's staged, uncommitted drill\nedits, which are session-local residue belonging to whoever opened the drill\n(gotcha 30, improvise-h0b).\n\nBefore improvise-jytm every client shared one layout, so a client seeing the host's\npending drill text was at least seeing it against the coordinates it was staged\nagainst: shared, arguably wrong, but coherent. That is improvise-tom1's territory.\n\nAfter the split a client can be on a DIFFERENT view, and `grid_in` derives a separate\n`GridLayout` for it. `display_value_in` is then handed that foreign layout while\n`drill_state.pending_edits` are keyed by `(row index, RecordCol)` against the HOST's\ndrill layout (gotcha 1). Row indices are positions, and positions in one layout mean\nnothing in another — so if that path is reachable, a client is shown another client's\nuncommitted text against coordinates it was never staged for.\n\nWhy it is probably not reachable today, and why \"probably\" is the reason to file it\nrather than to fix it blind: the `synthetic_record_info(key)` arm only fires for the\nsynthetic `(_Index, _Dim)` keys that records/drill mode produces, and a foreign\nlayout built by `GridLayout::new` carries no frozen records, so it should not produce\none. That argument is a read, not a test, and it is the kind of read this repo has\nbeen wrong about before.\n\nTwo things to do, in order: write the test that decides whether it is reachable, and\nif it is, stop `display_value_in` consulting `drill_state` for a layout that is not\nthe host's — the drill snapshot is the host's session-local residue and a foreign\nlayout is by definition not its addressee.\n\n## Steps to Reproduce\n\nNot confirmed reachable; establishing that is the first half of the work.\n\n1. Host opens a drill and types into a cell without committing, so\n `App::drill_state.pending_edits` is non-empty.\n2. A connected client switches to a different view (`Command::SwitchView`), so\n `grid_in` derives a layout that is not `App::layout`.\n3. Project that client's grid and read `CellDisplay::edit_text`.\n\nIf any cell's `edit_text` comes from `pending_edits`, that is the bug: the row index\nthose edits are keyed by addresses a row in the host's drill, not in this client's\nlayout.\n","acceptance_criteria":"A test establishes whether the drill arm of display_value_in is reachable with a layout that is not App::layout. If it is, drill_state is not consulted for a foreign layout. If it is not, the test stays as the guard that says so, because the argument for unreachability is currently a read rather than a check.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:24Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T04:43:49Z","started_at":"2026-09-21T03:27:55Z","closed_at":"2026-09-21T04:43:49Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -645,13 +780,13 @@ {"_type":"issue","id":"improvise-ni6k","title":"Invalidate the cursor by observing the grid frame, not by a per-effect table","description":"Successor to improvise-9mo9, proposed by the agent that implemented 9mo9 against its own work.\n\n9mo9 made ten effects call app.reset_cursor(). That is a PER-EFFECT TABLE, and this repo names that shape as the disease: 'a table of variants would be a second description of it, free to disagree' (gotcha 24, which says exactly this about why cursor_epoch is watched instead of matching on command variants).\n\nThe disagreement arrived the same day it was written: improvise-7kvw is SetPageSelection renumbering the grid under prune_empty without resetting — a gap in the new table, found immediately.\n\nThe repo's own preferred mechanism was available and not taken:\n * cursor_epoch beats matching on CycleAxis (gotcha 24)\n * document_generation beats matching on LoadModel (gotcha 39)\n * model_state.dirty beats a table of command variants (gotcha 45)\n\nAll three are observations. The equivalent here is to fingerprint the grid's FRAME (row/col identity, not contents) before and after a model batch in App::apply_effects, and reset the cursor when it moves. Then no effect declares anything, no new geometry effect can forget, and 7kvw closes as a consequence.\n\nThe agent's reason for not doing it is legitimate and recorded: the ticket and its acceptance criteria both asked for per-effect, and the change touches apply_effects, which WU-B and WU-E were editing concurrently. So this is a deliberate follow-up, not an oversight.\n\nTwo constraints any implementation must keep, both learned the hard way in 9mo9:\n 1. Reset only on SUCCESS. The epoch is document-wide, so a REFUSED add otherwise sends every client a ResetCursor for a change that never happened. The first version of 9mo9 had this bug with 1312 tests green; it was caught by reading the diff, not by a test.\n 2. The negative arm must stay tested, and there are now two arms that cannot see each other's bug: SetNumberFormat (succeeds, reshapes nothing) and a refused AddItem (reshapes nothing because it was refused).\n\n## Steps to Reproduce\n\nNot a defect report — 7kvw is the concrete instance. This ticket is the structural fix that makes instances like it unrepresentable.","acceptance_criteria":"reset_cursor is driven by an observation — the grid's frame fingerprinted before and after a model batch, reset when it moves — rather than by each effect declaring it. improvise-7kvw and any sibling gap close as a consequence rather than needing their own fix. The negative arm still holds: a batch that reshapes nothing invalidates nothing, including a REFUSED one.","notes":"THE GAP IS SIX COMMANDS WIDE, NOT ONE (enumerated 2026-09-20, at a6258f7, after\nthe adversarial review of PR #49). improvise-9mo9 closed the ten it named and\nfiled improvise-7kvw as \"the residual\". That framing undersells this ticket:\n7kvw is one instance of a class, and here is the whole class.\n\nEffects that call app.reset_cursor() -- twelve: AddCategory, AddItem,\nAddItemInGroup, CycleAxis, HideItem, RemoveCategory, RemoveItem, SetAxis,\nShowItem, ToggleGroup, TogglePruneEmpty, TransposeAxes.\n\nModelCommand variants that CAN renumber the grid and do NOT reset:\n - SetPageSelection -- under prune_empty, a different page prunes different\n rows (improvise-7kvw, already filed)\n - AddFormula / RemoveFormula -- a formula target is a _Measure item, so it is\n a column whenever _Measure is on Row or Column\n - SetCell / ClearCell -- under prune_empty, writing into or clearing the last\n value in a row makes that row appear or disappear\n - CleanEmptyRecords -- drops records, so rows, in records mode\n - SortData -- reorders records, which renumbers EVERY row in records mode\n while changing neither the row count nor any label\n\nSortData is the sharpest of these and the best argument for observing rather\nthan declaring: it is the one case where row_count() and the label set are both\nunchanged and every position still names a different record. A guard written as\n\"did the shape change\" would miss it; the frame fingerprint would not, because\nthe fingerprint is over row_items/col_items, which carry the labels IN ORDER.\n\nWHY THIS WAS NOT FIXED PER-EFFECT TOO: the F1 round of the review showed the\ncost. Six accepted-but-no-op commands each needed their own change-detector\n(SetAxis comparing try_axis_of, HideItem/ShowItem comparing is_hidden,\nAddItem/RemoveItem via a shared has_item, RemoveCategory via category().is_some())\n-- six chances to get it wrong, three of which had already been taken across two\nrounds. Adding six more effects to that table doubles the surface without\nchanging the shape of the mistake.\n\nTHE COST THAT STILL HAS TO BE MEASURED BEFORE DOING THIS: a per-batch frame\nfingerprint sits on the keypress path, and benches/grid_projection.rs (landed in\nPR #49) measures a whole-grid pass at ~150ms for 18,000 cells. A fingerprint over\nrow_items/col_items is much cheaper than grid_of -- it hashes labels, it does not\nevaluate cells -- but \"much cheaper\" is the kind of claim that ticket's own\nacceptance criterion exists to refuse. Benchmark it against the same fixture\nsizes before landing.\nTWO MORE INSTANCES, from the adversarial review of PR #49 (2026-09-20).\n\n(1) TogglePruneEmpty on a grid with nothing empty resets the cursor and bumps the\nepoch. It is the only residual no-op that is ORDINARY USE rather than a bad name,\nand it is ONE KEYSTROKE in the TUI. It is not guarded per-effect because guarding\nit means computing the pruned layout to find out whether pruning would change\nanything -- i.e. building the very frame this ticket proposes fingerprinting. It\nis the cleanest single argument for doing this the observed way.\n\n(2) HideItem / RemoveItem on a category parked on Axis::None: the data changes,\nrow_items/col_items do not. A frame fingerprint is right by construction here,\nwhere a per-effect predicate would need its own notion of \"visible axis\" -- and\nPage/Filter make that a judgement call (gotcha 27), not a lookup.\n\nWHAT PR #49 DID FIX PER-EFFECT, so this ticket is not inheriting it: SetAxis on an\nunregistered category, HideItem/ShowItem on a name the grid never drew,\nToggleGroup on a nonexistent group, and AddItem/AddItemInGroup/RemoveItem via an\neffective-items comparison across the mutation (which is itself an observation,\nand the reason it is correct in both directions at once).\n\nThe AddItem case is worth carrying into this ticket's design: the predicate that\nfailed read `category.items` while _Measure's effective items are explicit UNION\nformula targets (gotcha 7). Any fingerprint must be taken over what the GRID\ndraws, never over what the model stores.\nDONE except SortData, which is improvise-gfvj.\n\nClosed as a consequence: improvise-7kvw (SetPageSelection under prune_empty), pinned by paging_across_a_prune_that_drops_a_row_invalidates_the_cursor. Note 7kvw is claimed by claude-agent-cmd with an expired lease -- if that agent also patched it per-effect, the two changes overlap and the per-effect guard should go, since the observation covers it.\n\nAlso covered, with no arm of their own: AddFormula/RemoveFormula (added to the shared reshaping_commands table), TogglePruneEmpty on a grid with nothing empty, and HideItem/RemoveItem on a category parked on Axis::None -- all three now answer by observation.\n\nCost measured as the AC required, benches/reshape_observation.rs: +13% to +20% on one SetCell batch at 400 / 9.6k / 18k / 90k cells. The batch is dominated by rebuild_layout and recompute_formulas.\n\nTHE DESIGN POINT WORTH CARRYING: the cursor's question and layout_version's question are NOT the same, so this is a second observation beside view_shape and not a replacement for it. AddCategory of an empty category moves no row or column but does change the coordinates a client's CellKey hangs off. Unifying them broke tests in both directions -- that is how the distinction was found, not by reasoning.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T18:23:51Z","created_by":"orchestrator","updated_at":"2026-09-21T03:47:21Z","started_at":"2026-09-21T03:28:18Z","closed_at":"2026-09-21T03:47:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jna","title":"The browser page does not paint the title bar it is now told about","description":"improvise-z754 put the document's name, its dirty marker and what its host\ncalls it on `RenderModel` (`doc_name`, `doc_dirty`, `doc_stored_as`), fed by\n`Projection::Document` and `DocumentCache`. The TUI's `draw_title` now draws\nfrom that same derivation. `web/app.js` does NOT paint them: the header has\n`#view-name`, `#mode`, `#hint` and `#status` and no element for the document.\n\nThat omission is deliberate rather than forgotten. Gotcha 48 says a change to\n`web/*.js` belongs in `harness/tests/shell.spec.js`, and every fixture there\ntoday mocks a peer that FAILS — a refused upgrade, a bad frame, a socket that\ndrops. None drives a real document through the page, so a title-bar element\nadded now would be unrun JavaScript, which is exactly what that rule exists to\nstop.\n\nSo the work is two halves, and the first is the expensive one:\n\n1. A harness fixture that serves a real `Snapshot` to `web/app.js` — either a\n built wasm bundle against a mocked `ws` peer replaying recorded frames, or\n the worker path against a seeded OPFS origin. That fixture is worth more\n than this ticket: it is the first Playwright test that exercises the page's\n PAINT rather than its failure reporting.\n2. A `#doc-name` span, set from `model.doc_name` / `doc_dirty` / `doc_stored_as`,\n with the `[+]` marker spelled the way the TUI spells it.\n\nDo not do 2 without 1.\n","acceptance_criteria":"web/app.js paints the document name, its dirty marker and what the host calls it, from RenderModel's doc_name/doc_dirty/doc_stored_as — and a harness/tests/shell.spec.js fixture that serves a REAL Snapshot asserts it. The fixture comes first: gotcha 48 forbids unrun web/*.js, and every existing harness fixture mocks a FAILING peer, so none of them can see a painted title. Do not add the element without the fixture.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:11:01Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T04:43:20Z","started_at":"2026-09-21T03:28:12Z","closed_at":"2026-09-21T04:43:20Z","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rm4s","title":"The evidence check accepts headings naming tests that never run","description":"Found by adversarial review of PR #44 (finding F9), demonstrated with a built fixture that returned 'test-evidence: 3 entries, all name a test that exists', exit 0.\n\nscripts/check-comment-runs.py --check-evidence scrapes source text line by line for 'fn NAME' preceded by a test attribute. Three shapes defeat it:\n\n1. A test behind a cfg feature nobody enables — compiled out, never runs.\n2. A test attribute and fn inside a STRING LITERAL (an r-hash fixture). The scanner reads inside string literals, so this names a test that does not exist at all.\n3. A #[cfg(target_arch = wasm32)] test. This is the realistic one for THIS repo: it has real wasm32-only code (improvise-io/src/opfs.rs, improvise-worker-server) and a wasm CI job that only runs cargo check. Evidence keyed to such a test is accepted while cargo test --workspace never executes it.\n\nAll three are the same defect one layer up from the one the gate exists to prevent: evidence for a check that never runs.\n\nCheap fix the reviewer proposed and I verified is currently free: diff the heading set against the test names cargo actually reports (cargo test -- --list, or the run log) instead of scraping source. Cross-checking all 451 current headings against the executed test names from a real cargo test run came back EMPTY — every heading names a test that really ran — so adopting the stricter source costs nothing today.\n\n## Steps to Reproduce\n\n1. Create context/test-evidence/x.md with a heading naming a_test_that_is_only_text.\n2. Put a #[test] fn a_test_that_is_only_text() {} inside an r-hash string constant in any scanned .rs file.\n3. python3 scripts/check-comment-runs.py --check-evidence -\u003e passes, exit 0.","acceptance_criteria":"A heading naming a cfg-gated, wasm32-only, or string-literal 'test' is rejected; the check is diffed against the test names cargo actually reports rather than scraped from source text. Demonstrated by adding each of the three shapes and watching the check fail.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:03Z","created_by":"orchestrator","updated_at":"2026-09-21T04:49:28Z","closed_at":"2026-09-21T04:49:28Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","notes":"ANSWERED BY THE GOVERNING PRINCIPLE, user 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\"\n\nThis bead asked who sets a document's default view now that a switch does not. The principle answers it: the DEFAULT belongs to the DOCUMENT and a SWITCH belongs to the CLIENT, and they are different things that happen to name the same kind of value.\n\n - Workbook::active_view is the document's default -- what a freshly connected client opens, and what .improv writes as `Initial View`. It is shared state and changing it is a model change.\n - ViewState::active_view is authoritative FOR THAT CLIENT. Changing it is that client's business and reaches nobody else.\n\nSo \"who sets the default\" is not the same question as \"who switches\", and the bead conflated them because before improvise-jytm they were one field. The remaining REAL question is narrower: what user gesture, if any, should write the document default -- because today nothing does except loading a file, and improvise-azhb's original complaint was that `Initial View` no longer follows the TUI user.\n\nThat is now a small product question rather than an architectural one: should there be an explicit \"make this the document's default view\" action, or does the default only ever come from the file? Note that an implicit answer -- the TUI user's current view silently becoming the default -- is the one option the principle FORBIDS, because it would let one client's UI decide what every other client opens.\n\nFollow mode is deliberately not foreclosed (gotcha 29), but it would have to be opt-in and per-client, never a side effect of navigating.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-22T04:17:52Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-azhb","title":"Decide who sets a document's default view now that a switch does not","description":"A product question opened by improvise-jytm, which made `Workbook::active_view` the\ndocument's *default* — what a freshly connected client opens — and stopped a view\nswitch from moving it.\n\n`.improv` writes `Initial View` from the workbook (jytm's AC says it must), so the\ndefault is now frozen at load. For the multi-client story that is exactly right. For\nthe single-user TUI, which is the primary use today, it changes a behaviour people\nrely on: \"arrange the views, switch to the one I want this file to open on, save\".\nAfter jytm the file keeps whatever `Initial View` it was loaded with.\n\nThree shapes, none obviously right, which is why this is a ticket and not a fix:\n\n1. A host's own switch also sets the document default. Defensible — the host owns\n the document, there is exactly one of it, and the TUI is where `Initial View` is\n authored — but it re-couples the two copies in `App::switch_to_view`, the exact\n place jytm split them, and a headless ws-server would then have its phantom\n view_state authoring the default.\n2. An explicit command (`:set-default-view`, or a `w` variant). Unambiguous, costs\n the user a keystroke they did not used to need, and needs a keybinding argued.\n3. Leave it. The default is a document property you edit by editing the file.\n\nNothing is broken today in the sense of a wrong answer; the file is written\ncorrectly against the new meaning. What it is, is a silent change in what saving\nrecords, which is worth a decision rather than a discovery.\n\n## Steps to Reproduce\n\n1. `improvise model.improv` where the file says `Initial View: Default`.\n2. Switch to another view (`gv` / the view panel / `:switch-view Second`).\n3. Edit a cell so the document is dirty, then `:w`.\n4. Read the file: `Initial View: Default`. Before improvise-jytm it said `Second`.\n","acceptance_criteria":"A decision is recorded in context/code/command.md and, if it changes behaviour, implemented with a test asserting what a save writes as Initial View. Leaving it frozen at load is an acceptable outcome, but must be the stated answer rather than the default one.","notes":"ANSWERED BY THE GOVERNING PRINCIPLE, user 2026-09-21: \"The whole point of the view / server split is that multiple clients can have different views of the model.\"\n\nThis bead asked who sets a document's default view now that a switch does not. The principle answers it: the DEFAULT belongs to the DOCUMENT and a SWITCH belongs to the CLIENT, and they are different things that happen to name the same kind of value.\n\n - Workbook::active_view is the document's default -- what a freshly connected client opens, and what .improv writes as `Initial View`. It is shared state and changing it is a model change.\n - ViewState::active_view is authoritative FOR THAT CLIENT. Changing it is that client's business and reaches nobody else.\n\nSo \"who sets the default\" is not the same question as \"who switches\", and the bead conflated them because before improvise-jytm they were one field. The remaining REAL question is narrower: what user gesture, if any, should write the document default -- because today nothing does except loading a file, and improvise-azhb's original complaint was that `Initial View` no longer follows the TUI user.\n\nThat is now a small product question rather than an architectural one: should there be an explicit \"make this the document's default view\" action, or does the default only ever come from the file? Note that an implicit answer -- the TUI user's current view silently becoming the default -- is the one option the principle FORBIDS, because it would let one client's UI decide what every other client opens.\n\nFollow mode is deliberately not foreclosed (gotcha 29), but it would have to be opt-in and per-client, never a side effect of navigating.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:06:00Z","created_by":"wave2-e-active-view","updated_at":"2026-09-22T04:17:52Z","labels":["area:active-view"],"dependencies":[{"issue_id":"improvise-azhb","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:03Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ahdf","title":"repo-map gotchas 36 and 43 still name Sessions::apply as the production door","description":"Found by adversarial review of PR #45 (finding F11).\n\nVerified: Sessions::apply has NO production caller. Host calls apply_checked (crates/improvise-command/src/ui/host.rs:144); apply (project.rs:774) delegates to apply_checked (:783) after Checked::against, and its only call sites are tests. context/code/command.md already records this.\n\nTwo repo-map gotchas still say otherwise:\n * gotcha 36: 'Sessions::apply(app, id, cmd) is the only way a connected client's Command reaches the server'\n * gotcha 43: 'Sessions::apply answers once, before the dispatch'\n\nBoth are PRE-EXISTING rather than introduced by #45 — but they are the siblings of the sentence #45 fixed in design-principles §5, and gotcha 48 is the rule they break: 'when a fact is stated in more than one place, changing it is a grep, not an edit.' #45 fixed §5 and the now-false note in command.md; these two were left because they sit inside long paragraphs whose surrounding argument needs reading, not a sed.\n\n## Steps to Reproduce\n\n1. grep -n 'Sessions::apply' context/repo-map.md\n2. Compare against crates/improvise-command/src/ui/host.rs:144 and the note in context/code/command.md.\n\nNote the claims are not merely imprecise: a future agent told that apply is 'the only way' may add a caller to it, bypassing the CheckedBatch frame-level answer that host.rs actually performs.","acceptance_criteria":"Gotchas 36 and 43 name apply_checked where they mean the door production uses, or say explicitly that apply is the single-command convenience its tests drive; a grep for 'Sessions::apply' across context/ finds no claim that contradicts context/code/command.md.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T18:05:51Z","created_by":"orchestrator","updated_at":"2026-09-21T04:44:18Z","closed_at":"2026-09-21T04:44:18Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4bwa","title":"Three bindings improvise-e24v listed as needing no enrichment do need it","description":"improvise-e24v listed these under \"the operands are already in ViewState or a\nwidget cache\". Closing the tranche established that each of the three is short\nexactly one fact, and each wants a different answer.\n\ntoggle-prune-empty. TogglePruneEmpty::execute reads ctx.view.prune_empty to word\nits status (\"Showing all rows/columns\" vs \"Hiding empty rows/columns\").\nView::prune_empty is on the View inside the Workbook; it is in no widget cache\nand not in ViewState. A client can compose ModelCommand::TogglePruneEmpty but\ncannot compose the sentence beside it, so mirroring it would diverge on the\nstatus line. Cheapest fix: the flag is one bool on a per-view projection.\n\nview-back. ViewNavigate { forward: false } emits ApplyAndClearDrill *before*\nViewBack, and ApplyAndClearDrill is deliberately off the wire (gotcha 38,\nimprovise-3x3u) because its operand is another session's uncommitted drill\nedits. So this is not an enrichment at all: it is blocked on ADR 001 layer 2's\naddressed form. Leave it upstream and say so.\n\ndelete-formula-at-cursor. The effect is RemoveFormula { target,\ntarget_category }. FormulaCache carries the formula's *raw text*\n(\"Profit = Revenue - Cost\"), from which neither field is recoverable without\nreimplementing parse_formula in the client. Cheapest fix: FormulaCache carries\nthe target and its category beside the text.","acceptance_criteria":"Each of the three is either closed with the enrichment named in its\nparagraph, or declined in writing with the reason. toggle-prune-empty and\ndelete-formula-at-cursor are ordinary projection additions; view-back is NOT\nand must not be mirrored by reconstructing ApplyAndClearDrill on the client,\nwhich is the exact thing improvise-3x3u removed. Whatever lands, the named\nlist the_command_names_that_still_need_the_host_are_these shrinks by exactly\nwhat closed.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:14Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T04:43:22Z","started_at":"2026-09-21T03:28:13Z","closed_at":"2026-09-21T04:43:22Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2oxt","title":"The landing page's casts predate the records columns and show the recorder's shell prompt","description":"The four casts under `docs/casts/` were recorded 2026-04-09 (improvise-d4w) and\nare now embedded on the landing page `docs/index.html` (improvise-e61), served\nat `/landing/`. improvise-e61's acceptance criteria asked for them to be checked\nagainst the current UI before embedding. They were, and three things came back.\n\n1. THEY PREDATE THE RECORDS COLUMNS. `improvise-2zq`'s `+cat` column landed in\n `6a929c1` on 2026-09-19 and `improvise-c5v`'s `+` measure column with it —\n five months after the recording. `drill.cast` shows records/drill mode as it\n was BEFORE either column existed, so the drill cast is the one that is\n materially wrong rather than merely old. What still matches: the tile bar's\n `short_label()` vocabulary (`Row`/`Col`/`Pag`/`·`) and the mode line, both\n verified against `crates/improvise-core/src/view/axis.rs`.\n\n2. THEY CARRY THE RECORDER'S SHELL PROMPT. Every cast opens and closes on\n `(0) Mac:edwlan--s059 ~gf/u/edwlan/improvise git-\u003emain \u003cdate\u003e`, which is a\n local hostname and home-directory path on a public page. `formulas.cast` and\n `import.cast` are the worst: the visible opening frame is the prompt, not\n improvise.\n\n3. THE GEOMETRY DISAGREES WITH ITS OWN TICKET. improvise-d4w's description says\n \"Terminal: 100x30\" and improvise-e61 repeats it as the player config; the\n cast headers say `\"width\": 120, \"height\": 37`. The landing page therefore\n sets `cols: 120, rows: 37`, because a 30-row player clips the bottom seven\n rows of a 37-row cast — which is exactly where the tile bar and status line\n are. The recordings also use only about twenty of the thirty-seven rows, so\n each player is roughly half empty.\n\nRE-RECORDING FIXES ALL THREE AT ONCE and needs nothing built: record at the\ngeometry the page declares, from a clean prompt (`env -i` or a bare `PS1`), and\nmake the drill cast show the `+cat`/`+` columns that now exist. Then update the\n`cols`/`rows` in `docs/index.html` if the new geometry differs.\n\n## Steps to Reproduce\n\n1. `python3 -m http.server 8123 --directory docs` and open\n `http://127.0.0.1:8123/index.html`.\n2. Press play on \"Formulas over dimensions\": the first frame is a zsh prompt\n reading `Mac:edwlan--s059 ~gf/u/edwlan/improvise`.\n3. Press play on \"Drill into an aggregate\" and watch the records grid: there is\n no `+cat` column and no `+` measure column, both of which the current build\n draws (`crates/improvise-core/src/view/layout.rs::records_columns`).\n4. `head -c 200 docs/casts/pivot.cast` — the header says `\"width\": 120,\n \"height\": 37`, not the 100x30 improvise-d4w's description claims.","acceptance_criteria":"All four casts under docs/casts/ are re-recorded from a clean prompt (no hostname or home-directory path in any frame), at a geometry matching docs/index.html's cols/rows, with drill.cast showing the current records grid including the +cat and + measure columns. docs/index.html's player config is updated if the new geometry differs. Verified by playing each of the four on the served page and reading the first frame.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:04Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T04:43:20Z","started_at":"2026-09-21T03:28:15Z","closed_at":"2026-09-21T04:43:20Z","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-7kvw","title":"SetPageSelection renumbers the grid under prune_empty and does not reset the cursor","description":"Left open by improvise-9mo9, which decided the other nine geometry effects and\nstopped here on purpose.\n\n`SetPageSelection` changes which records are visible, not which categories frame\nthem, so normally a grid position keeps naming the same cell. Under\n`View::prune_empty` that is false: pruning drops rows and columns whose cells are\nall empty, and which rows those are is a function of the page selection. Flipping\na page therefore renumbers the grid while `SetPageSelection` calls neither\n`app.reset_cursor()` nor anything that bumps `cursor_epoch`, so the host's cursor\nis stale and no client is told (gotcha 24's both-directions failure).\n\nWHY 9mo9 DID NOT JUST PATCH IT: `[` / `]` page cycling is navigation the user does\nconstantly, and sending the cursor to the origin on every page flip is a worse\nanswer than the hole when `prune_empty` is off — which is the default. A reset\nconditional on `view.prune_empty` is the obvious shape and is a decision about\nintended UX, not a patch.\n\n## Steps to Reproduce\n\n1. Open a view with a Page category and `prune_empty` on, where two pages have\n different sets of non-empty rows.\n2. Put the cursor somewhere other than the origin, on a row that exists on this\n page but not the next.\n3. Page across. The grid renumbers and the cursor is not reset, so it addresses a\n position that has moved; no `Command::ResetCursor` reaches any other client\n either, because `cursor_epoch` never moved.","acceptance_criteria":"SetPageSelection either resets the cursor when the active view has prune_empty on, or carries the reason it does not — decided the same way improvise-9mo9 decided the other nine. If it resets, every_reshaping_model_command_invalidates_every_cursor gains a case built on a prune_empty view where two pages really do prune different rows; the case must fail the fixture's non-vacuity assertion if the pages ever stop differing.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T17:57:25Z","created_by":"wave2-n-cleanups","updated_at":"2026-09-21T04:43:48Z","started_at":"2026-09-21T03:27:54Z","closed_at":"2026-09-21T04:43:48Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-7kvw","depends_on_id":"improvise-ni6k","type":"related","created_at":"2026-09-20T18:24:00Z","created_by":"orchestrator","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mbmf","title":"An assertion message in gp-tree states the opposite of what it checks","description":"crates/gp-tree/src/fit.rs, in the_ranking_is_ordered_shares_with_the_unused_features_dropped:\n\n assert_eq!(ranked.len(), 2, \"the zero-importance feature is listed\");\n\nimportance_ranking is given three features, one of zero importance, and the\nassertion requires TWO entries back — i.e. the zero one was dropped. The\nmessage says it is listed.\n\nThe message is only ever read on failure, which is exactly when someone is\ntrying to work out what the intended behaviour was; here it would tell them\nthe opposite. The test name and the doc comment both say 'dropped', so the\nmessage is the only wrong copy of three.\n\n## Steps to Reproduce\n\n1. Change importance_ranking to list zero-importance features at 0%.\n2. cargo test -p gp-tree the_ranking_is_ordered\n3. The failure reads 'the zero-importance feature is listed' — describing the\n new (wrong) behaviour as though it were the requirement.\n\nFound during the 2026-09-20 comment-cleanup wave review.","acceptance_criteria":"The message names what the assertion actually requires (the zero-importance feature is dropped); a deliberate injection of a 3-entry ranking makes the test fail with a message that reads correctly.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T17:49:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:12:59Z","closed_at":"2026-09-20T18:12:59Z","close_reason":"Fixed in PR #45 (commit 56ff51e): the assertion message in the_ranking_is_ordered_shares_with_the_unused_features_dropped now says 'the zero-importance feature is dropped', matching what the assertion checks and what the test name and doc comment already said. The Closes: trailer was written before the bead was actually closed — flagged by adversarial review as a dead promise of the same class as a dead ticket ID.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a1ec","title":"Eight pub doors have no production caller — decide seam or dead code for each","description":"Found by the post-wave code consistency review. A grooming sweep rather than a defect: none of these is urgent alone, and filing nine tickets would be worse than one.\n\n`pub` items with NO PRODUCTION CALLER (test modules stripped before counting):\n\n * DataStore::live_records — cell.rs:368. improvise-2yc2 decided its ORDER and closed; the door itself stayed. Its intended caller is the ADR 002 surrogate path.\n * App::resolve_aggregate — app.rs:410. An ADR 002 door; the resolver exists and is tested, the command that would use it is improvise-gd28 (blocked on ADR 001 layer 2).\n * AxisFrame::leaf_labels — render.rs:268. NEW THIS WAVE, and deliberately not \"the labels\" (see gotcha 44); worth checking whether anything was meant to call it.\n * CellDisplay::with_id — render.rs:171\n * App::cursor_in — app.rs:292\n * CmdContext::synthetic_record_at_cursor — cmd/core.rs:73\n * CmdContext::cat_at_cursor — cmd/core.rs:99\n * gp_tree::retain_valid — lib.rs:455\n\nAlready filed separately and NOT part of this sweep: Sessions::apply (project.rs:1083, now documented as having no production caller) and reduce_full (improvise-s17o).\n\nWHY IT IS WORTH A PASS RATHER THAN A SHRUG: doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind). The risk is not the dead code, it is that a `pub` door with no caller has no user keeping its behaviour honest, and the next caller inherits whatever it happens to do. Several of these are deliberate seams waiting for a specific ticket — those should SAY SO on the item, naming the ticket, which is cheap and turns a smell into a plan.\n","acceptance_criteria":"Each of the eight is either deleted, made pub(crate) or private, or carries a one-line doc naming the ticket it is a seam for. No item is left pub, uncalled and unexplained. Where an item is kept as a seam, bd show the cited ticket to confirm it is real and open — a dead ID here is worse than the dead code.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:12Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a1ec","title":"Eight pub doors have no production caller — decide seam or dead code for each","description":"Found by the post-wave code consistency review. A grooming sweep rather than a defect: none of these is urgent alone, and filing nine tickets would be worse than one.\n\n`pub` items with NO PRODUCTION CALLER (test modules stripped before counting):\n\n * DataStore::live_records — cell.rs:368. improvise-2yc2 decided its ORDER and closed; the door itself stayed. Its intended caller is the ADR 002 surrogate path.\n * App::resolve_aggregate — app.rs:410. An ADR 002 door; the resolver exists and is tested, the command that would use it is improvise-gd28 (blocked on ADR 001 layer 2).\n * AxisFrame::leaf_labels — render.rs:268. NEW THIS WAVE, and deliberately not \"the labels\" (see gotcha 44); worth checking whether anything was meant to call it.\n * CellDisplay::with_id — render.rs:171\n * App::cursor_in — app.rs:292\n * CmdContext::synthetic_record_at_cursor — cmd/core.rs:73\n * CmdContext::cat_at_cursor — cmd/core.rs:99\n * gp_tree::retain_valid — lib.rs:455\n\nAlready filed separately and NOT part of this sweep: Sessions::apply (project.rs:1083, now documented as having no production caller) and reduce_full (improvise-s17o).\n\nWHY IT IS WORTH A PASS RATHER THAN A SHRUG: doctrine(a_documented_door_with_no_caller_is_the_dangerous_kind). The risk is not the dead code, it is that a `pub` door with no caller has no user keeping its behaviour honest, and the next caller inherits whatever it happens to do. Several of these are deliberate seams waiting for a specific ticket — those should SAY SO on the item, naming the ticket, which is cheap and turns a smell into a plan.\n","acceptance_criteria":"Each of the eight is either deleted, made pub(crate) or private, or carries a one-line doc naming the ticket it is a seam for. No item is left pub, uncalled and unexplained. Where an item is kept as a seam, bd show the cited ticket to confirm it is real and open — a dead ID here is worse than the dead code.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:50:12Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-a1ec","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:11Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-23qd","title":"The Projection::Basis record set is derived twice, and the second copy is the tests' yardstick","description":"Found by the post-wave code consistency review.\n\nThe set of records a client's Projection::Basis should carry is derived TWICE:\n * crates/improvise-command/src/ui/project.rs:883-892 — ClientSession::basis_update, the producer\n * crates/improvise-command/src/ui/project.rs:1350-1364 — `server_truth`, a #[cfg(test)] helper\n\nand `server_truth` IS THE GROUND TRUTH the tests measure against (used at :1632, :1660, :2511). It re-implements \"which records does this client's grid name\" rather than asking the producer.\n\nSo a change to WHICH records get published would move both together and be invisible to every test measured against it. That is doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing) in its most dangerous form — not a helper that fabricates an input, but a helper that fabricates the EXPECTED OUTPUT.\n\nThe fix is small: have server_truth call the same filter, or extract it as a pub(crate) fn both use. What must not happen is the two staying separate with a comment saying they agree.\n\n## Steps to Reproduce\n\nNot a runtime failure — a test-integrity one, which is why it is filed rather than patched.\n\n1. Change which records `ClientSession::basis_update` publishes (`project.rs:883-892`).\n2. Make the matching change in `server_truth` (`project.rs:1350-1364`), the `#[cfg(test)]` helper the basis tests measure against at `:1632`, `:1660` and `:2511`.\n3. Every test passes, whatever the change was — including one that publishes the wrong set — because the yardstick moved with the thing it measures.\n","acceptance_criteria":"server_truth calls the same filter the producer uses, or the filter is extracted as a pub(crate) fn with both calling it. Changing which records are published then moves the producer and the yardstick together by construction, and a test proves it: alter the filter and watch the basis tests fail rather than silently track it.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:00Z","started_at":"2026-09-21T03:28:17Z","closed_at":"2026-09-21T03:47:00Z","close_reason":"Closed","labels":["area:protocol"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-e1z5","title":"AppMode::minibuffer and strands_the_user opt out of exhaustiveness, and both fail silently","description":"Found by the post-wave code consistency review. Two instances of one shape, filed together because the fix is identical and trivial.\n\n1. `AppMode::minibuffer()` (crates/improvise-protocol/src/lib.rs:322-332) ends in `_ =\u003e None`, where its sibling `panel()` (:303-320) is exhaustive. SavePrompt — added this wave — had to be listed BY HAND, and the compiler could not have caught its omission. The failure mode is silent: a text-entry mode whose minibuffer is invisible to EnterMode's clear, to keys.rs::entered, and to the TextEntry builder.\n\n2. `strands_the_user` (crates/improvise-wasm-client/src/keys.rs:498-503) is a `matches!` over AppMode. Its own doc says \"a third such prompt belongs here\" — and a fourth would answer false in silence, which is precisely the dead-room bug found against SavePrompt in the first place (doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key)).\n\nBoth are one or-pattern arm away from being exhaustive, at no cost. The repo's compiler-exhaustiveness theme says business rules go through the enum so the compiler names every site; these two opted out.\n","acceptance_criteria":"Both are exhaustive matches over AppMode with no wildcard, so adding a variant fails the build until someone answers for it. Verified by adding a throwaway AppMode variant and watching both sites fail to compile.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:02Z","started_at":"2026-09-21T03:28:19Z","closed_at":"2026-09-21T03:47:02Z","close_reason":"Closed","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-seik","title":"Client::viewport is kept for a reason web/app.js falsified in the same wave","description":"Found by the post-wave code consistency review.\n\ncrates/improvise-wasm-client/src/lib.rs:141-158. `Client::viewport` is a #[wasm_bindgen]-exported door with NO PRODUCTION CALLER. Its doc says it is \"kept because web/app.js already spells it and retiring it is a co-ordinated change\" — and web/app.js:137-144 now says the OPPOSITE in its own comment and calls `set_viewport`.\n\nSo the justification for keeping it was falsified IN THE SAME WAVE that wrote it.\n\nThis matters more than an ordinary unused function because its documented failure mode is SILENT: calling `viewport` instead of `set_viewport` does not error, it quietly turns a whole feature off — the window is not recorded, so cursor keys stop resolving locally and every one of them becomes an upstream black hole (doctrine(an_upstream_key_is_a_black_hole)). Gotcha 47 is explicit that the announced window is NOT derivable from row_labels.len(), which is the window clipped to the grid.\n\ndoctrine(a_documented_door_with_no_caller_is_the_dangerous_kind), with the justification removed.\n\nSIBLING, same file, lower severity: `Client::render_state` (lib.rs:120-124) is exported to JS with ZERO callers anywhere, while the crate's module doc (lib.rs:8) still names it as what the DOM renderer consumes. The page consumes `render_model`. `revision()` is likewise uncalled by the page, but `on_frame` returns the revision anyway, so that one is harmless.\n","acceptance_criteria":"Client::viewport is deleted and its tests move onto set_viewport, or its doc states a reason that is true at HEAD. Same for render_state: deleted, or the crate module doc stops naming it as what the DOM renderer consumes. Check web/app.js and the generated .d.ts before deleting — the page is not compiled against the crate, so a removed export is a runtime failure there, not a build error.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-browser-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T07:49:55Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:21Z","started_at":"2026-09-21T03:28:14Z","closed_at":"2026-09-21T04:43:21Z","labels":["area:browser"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -660,7 +795,7 @@ {"_type":"issue","id":"improvise-gd83","title":"AxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group","description":"Found by Copilot on PR #23 (improvise-anr9), DECLINED there with reasons on the thread, and filed so it is a decision rather than a rediscovery. COPILOT IS RIGHT AND THIS IS A REAL BUG.\n\nAxisFrame::group_of answers with the NEAREST PRECEDING GROUP HEADER, which is not the same as the item's group membership. GridLayout::expand_category emits a header on ENTERING a group and NO BOUNDARY ON LEAVING ONE, so an ungrouped item appearing after a grouped run inherits that group -- in the frame, and in the span the TUI paints.\n\nIT IS GridLayout's BUG, NOT THE FRAME'S. The TUI has painted that span forever; improvise-anr9 faithfully reproduced the existing rule when it moved the frame onto the wire, which is what a migration should do (doctrine(characterization_survives_the_move)). What changed is that the rule is now WRITTEN DOWN and therefore visible.\n\nWHY IT WAS RIGHT TO DECLINE IT THERE: fixing it means changing AxisEntry in improvise-core to carry a group-end boundary, and changing what the TUI DRAWS -- which would destroy `the_whole_frame_is_unchanged`, the exact-buffer characterization that was PR #23's only evidence the migration changed nothing. Same shape as improvise-q0vb's decline on PR #12: a finding that is correct and still wrong to fix in that PR, because of what it costs in evidence.\n\nTHE EVIDENCE IS ALREADY WRITTEN AND IT IS THE GOOD KIND. `the_two_front_ends_draw_the_same_column_header` asserts the WRONG answer as the current one -- Q1 spanning all six columns -- with THE CORRECTED EXPECTATION `[(Some(\"Q1\"), 4), (None, 2)]` WRITTEN BESIDE IT. So the day someone fixes AxisEntry, that test fails and the fix is one line of expectation away, with the intended answer already argued. The doc on group_of states the rule and names the defect.\n\nWHAT THE FIX COSTS, so nobody starts it casually: AxisEntry is in improvise-core; a group-end boundary changes the shape every consumer of row_items/col_items walks; and the characterization buffers in src/ui/grid.rs move, which means re-pinning them deliberately and saying so in the commit, exactly as improvise-q0vb's acceptance clause required.\n\n## Steps to Reproduce\n\n1. Build an axis with a grouped run followed by an UNGROUPED item — `expand_category` emits a header on entering the group and no boundary on leaving it.\n2. Ask `AxisFrame::group_of(idx)` for the ungrouped item's index.\n3. It answers with the nearest preceding header, so the ungrouped item is reported as a member of the group that ended before it.\n4. The TUI paints the same span, so this is `GridLayout`'s rule faithfully reproduced rather than a frame defect.\n\n`the_two_front_ends_draw_the_same_column_header` in `tests/grid_frame_agreement.rs` asserts the wrong answer as the current one, with `THE_MEMBERSHIP_CORRECT_SPANS` and an `assert_ne!` beside it that starts failing the day this is fixed.\n","acceptance_criteria":"AxisEntry carries a group-end boundary, so group_of answers membership rather than nearest-preceding. THE_MEMBERSHIP_CORRECT_SPANS in tests/grid_frame_agreement.rs becomes the asserted value and its assert_ne! is removed. The TUI's painted spans change, so the characterization buffers move deliberately and the commit says so.","status":"closed","priority":3,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:16:26Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:55Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-xps8","title":"The handshake watchdog overwrites a genuine mid-session disconnect eight seconds later","description":"Found by improvise-vvl3's harness (PR #24) -- the first time web/app.js had ever been executed -- and it is a THIRD bug in the same family, distinct from both of the ones already dealt with.\n\nTHE SEQUENCE, measured rather than argued:\n\n open-then-close: connecting... -\u003e connected -\u003e disconnected\n -\u003e \"accepted the connection but sent no document\" \u003c- EIGHT SECONDS LATER\n\nOn the open-then-close arm the handshake watchdog IS armed (armWatchdog runs after transport.ready, which resolves on `open`), and nothing disarms it when the socket later closes. So a genuine mid-session disconnect is correctly reported, and then silently replaced eight seconds later by a message that is FALSE -- the peer did not fail to answer a handshake, it answered and then went away.\n\nIT SURVIVES improvise-taxx'S FIX, confirmed rather than assumed: the harness simulated the `opened` flag and the assertion still fails. taxx distinguishes never-connected from was-connected-and-dropped in the CLOSE listener; this is the TIMER overwriting whatever that listener concluded. Different mechanism, same surface.\n\nIT IS THE SAME CLASS AS THE BUG setShellFailure ALREADY FIXED -- a timer firing later and replacing a more precise message (gotcha 48, \"a watchdog must be disarmed, not pre-empted\"). The fix for that one cleared the timer on a LOUD failure. This arm is not loud: a close after a successful open is an ordinary event, not an error, so it never reaches setShellFailure. The likely answer is that the close listener disarms the watchdog on the opened path, since once a socket has opened the handshake question is settled either way -- but check whether any path legitimately wants the watchdog to survive a close before assuming it.\n\nWHY IT IS NOT A CI GUARD, and this is the part worth reading before trying to test it: the harness shipped an assertion for this and HAD TO REMOVE IT. It reproduces on macOS/Chromium and NOT on the GitHub runner, because the trigger is the ORDER of error/close/timer on the host's network stack. A Playwright test.fail is green when the bug is present and red when it is absent, so an expected-failure whose trigger is platform-dependent does not merely flake -- IT INVERTS CI'S MEANING, and it failed the browser job twice reporting \"expected to fail, but passed\". See doctrine(an_expected_failure_must_not_be_platform_dependent). The measurement and the reproduction recipe are recorded in harness/tests/shell.spec.js.\n\nNOTE THE SAME CAVEAT NOW APPLIES TO improvise-taxx: its bug is also only locally reproducible, which is why the two assertions the fix owed could not be shipped as guards either. The fix is still right; it is the pinning that is unavailable.\n\n## Steps to Reproduce\n\n1. Serve the page and point it at a reachable `improvise-ws-server` so the socket actually OPENS (status reads `connected`).\n2. Stop the server, or otherwise drop the connection mid-session.\n3. The status line reads `disconnected` — correct.\n4. Wait eight seconds. The handshake watchdog, armed on open and never disarmed, replaces it with `accepted the connection but sent no document`.\n\nReproduces on macOS/Chromium. Does NOT reproduce on the GitHub runner, where the order of `error`/`close`/timer on the host's network stack differs — which is why this cannot be pinned as a CI guard. See `harness/tests/shell.spec.js` for the measurement.\n","acceptance_criteria":"The watchdog is disarmed when a socket that had OPENED subsequently closes, so a mid-session disconnect keeps its own message. The never-opened path is unchanged (the watchdog is the only thing that speaks there). Stated in the code why this is not a CI guard: the trigger is the host network stack's ordering of error/close/timer, so an expected-failure would invert CI's meaning rather than flake.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T06:44:26Z","created_by":"Edward Langley","updated_at":"2026-09-20T08:05:26Z","closed_at":"2026-09-20T08:05:26Z","close_reason":"CLOSED AS INVALID 2026-09-20. THE BUG DOES NOT EXIST, and I filed it without checking the merged code.\n\nTHE CLAIM WAS: on the open-then-close path the handshake watchdog stays armed, so eight seconds after a genuine mid-session disconnect the status line replaces \"disconnected\" with \"accepted the connection but sent no document\".\n\nTHE CODE SAYS OTHERWISE, verified by reading at main 22d43b2:\n * web/app.js:248-250 routes BOTH arms of the close listener through setShellFailure.\n * setShellFailure (app.js:165-169) calls clearTimeout(watchdog) synchronously and nulls it.\n * The comment at app.js:241-247 states the property explicitly and ends \"Neither arm leaves one armed.\"\n\nHOW A REAL MEASUREMENT PRODUCED A FALSE BEAD, which is the part worth keeping:\n\nThe harness agent (improvise-vvl3) MEASURED the 8s overwrite for real. But its worktree was based at 97ea489 -- BEFORE PR #21 landed improvise-taxx's fix. It then reported the bug \"survives improvise-taxx's fix, confirmed by simulating PR #21's opened flag\". THAT SIMULATION WAS INCOMPLETE. PR #21's fix is not only the `opened` flag; it is routing BOTH ARMS THROUGH setShellFailure. Simulating the flag alone leaves the timer armed and faithfully reproduces a bug the real fix does not have.\n\nSo the measurement was honest, the simulation was not the fix it named, and I filed the bead on the report without checking the merged tree. doctrine(an_injected_fault_must_be_the_fault_you_named) one level up: A SIMULATED FIX MUST BE THE FIX YOU NAMED. A partial simulation of a repair reproduces the original bug by construction and reads as evidence that the repair failed.\n\nFOUND BY: a reviewer (ChatGPT) on PR #30, which had propagated the false claim into context/repo-map.md's gotcha 48 and into harness/tests/shell.spec.js's comment block. Both are being corrected.\n\nNOT WASTED: PR #30 adds the test the claim should always have had -- the open-then-close arm now runs past HANDSHAKE_TIMEOUT_MS and asserts \"sent no document\" never appears, proved discriminating by making setShellFailure not clear the timer. So the property is executed rather than argued, and the next person cannot re-derive this conclusion from a partial simulation.\n\nimprovise-taxx (the close-listener fix) remains correctly closed and is unaffected.","dependencies":[{"issue_id":"improvise-xps8","depends_on_id":"improvise-taxx","type":"relates-to","created_at":"2026-09-20T06:44:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-l8rh","title":"The ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped","description":"Named and pinned by WU-L on PR #22 (improvise-7yx item 2), and left UNCHANGED by the user's ruling 2026-09-19: leave it pinned, file it as a known limit.\n\nTHE BEHAVIOUR: import/analyzer.rs::detect_date_format is a FIRST-MATCH heuristic, so for a column of values that fit both %m/%d/%Y and %d/%m/%Y -- \"01/02/2025\", \"03/04/2025\" -- the AMERICAN reading wins. A European CSV therefore imports with days and months SWAPPED, silently, and every date in the column is wrong in a way that still looks like a date.\n\nWHY IT IS TRACKED RATHER THAN FIXED, and this is the argument that carried:\n * The import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks. That is the same argument detect_short_year_format already recorded for its own guess.\n * Changing the default to day-first only moves which half of the world imports silently wrong.\n * The honest fix is larger than the heuristic: an AMBIGUOUS column should surface its ambiguity rather than resolve it, which changes the analyzer's return shape (FieldProposal) and the wizard step that displays it. Worth doing deliberately, not as a patch.\n\nWHAT PR #22 DID CHANGE, and it is why this is now a stable known limit rather than a lottery: analyze_records used to iterate a HashSet, so the ten samples detect_date_format looks at were a DIFFERENT TEN EVERY RUN -- meaning a column whose eleventh value rules out %m/%d/%Y detected a different format run to run. Same CSV, same import, different dates. That is fixed (first-seen order, invariant(analyzer_first_seen_order)), so the heuristic is now WRONG DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\nPINNED BY: an_ambiguous_date_is_read_american_first, whose doc-comment states plainly that this silently mis-reads a European CSV, with the day-first reading asserted to parse so the test is a statement about AMBIGUITY rather than about parsing. a_day_first_only_value_settles_the_ambiguity is the discriminating other arm.\n\nSTILL TRUE AND UNCHANGED: the ten-sample window itself. A column whose first ten values are ambiguous and whose eleventh is not will still be read american-first. Now deterministic, still a limit.\n\n## Decision\n\nLeave the first-match heuristic as it is, and track it as a known limit. Ruled by the user 2026-09-20.\n\n## Rationale\n\nThe import wizard SHOWS the detected format and lets the user override it, so the mis-read is visible at the point of import to a user who looks — the same argument `detect_short_year_format` already recorded for its own guess.\n\nWhat made it worth tracking rather than ignoring is that PR #22 changed its character: `analyze_records` used to iterate a `HashSet`, so the ten samples `detect_date_format` inspects were a DIFFERENT ten every run, and a column whose eleventh value ruled out `%m/%d/%Y` detected a different format run to run. It is now wrong DETERMINISTICALLY, which is the precondition for anyone being able to reason about it at all.\n\n## Alternatives Considered\n\n**Change the default to day-first.** Rejected: it only moves which half of the world imports silently wrong.\n\n**Surface the ambiguity instead of resolving it.** The honest fix and larger than the heuristic — it changes `FieldProposal`'s shape and the wizard step that displays it. Worth doing deliberately rather than as a patch, which is why it is not folded in here.\n\n**Widen the ten-sample window.** Does not address ambiguity at all; a fully ambiguous column stays fully ambiguous.\n","status":"closed","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:28:43Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:42:56Z","closed_at":"2026-09-21T04:42:56Z","close_reason":"Closed","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-rw3a","depends_on_id":"improvise-2ud","type":"blocks","created_at":"2026-09-20T16:20:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-rw3a","title":"Decide whether ZZ routes into the save prompt automatically","description":"The half of improvise-lae that was deliberately left open. PR #27 built the prompt (AppMode::SavePrompt, TextEntry builder, committing to SaveAs, bound Ctrl-W and reachable as `:enter-mode save-prompt`) and stopped short of making ZZ route into it.\n\nTODAY, after PR #27: ZZ on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it -- the user is told where to go. The open question is whether being told is enough, or whether the vim reflex should just land somewhere useful.\n\nTHE OBSTACLE THE ORIGINAL TICKET NAMED, still true: CmdContext does not expose file_path, so a Cmd cannot currently decide \"is there a path\" at execute time. Adding the field is small; note improvise-2ud is separately proposing that CmdContext borrow the ModelState/ViewState slices rather than copying ~20 individual fields, which would make this free. Coordinate rather than adding a 21st copied field ahead of it.\n\nTHE CONSTRAINT PR #27 ESTABLISHED, and it must be respected by whatever lands here: the prompt is SESSION-LOCAL and was deliberately kept in the COMMAND layer rather than routed through effect::Save. Save is becoming a wire command (improvise-368h), and a prompt is addressed to precisely one session -- routing it through the effect would open a prompt on a host nobody asked, which is gotcha 38's argument for keeping AcceptWizardModel off the wire. So: a Cmd that inspects the path and emits a mode change, never an effect that decides to prompt.\n\nDECIDE ALSO what ZZ means once the prompt is open. ZZ is \"save and quit\"; if the prompt commits to SaveAs and the batch was aborted, the quit is gone. Should committing the path then quit? That is a second composition and the answer is not obviously yes -- a user who hit ZZ, got a prompt, and typed a path plausibly wants to leave, but making the prompt's Enter mode-dependent is the kind of hidden state improvise-ct5 just declined to add for `i`.\n\n## Decision\n\nOpen. This is the half of improvise-lae that was deliberately left undecided when the save prompt landed.\n\n## Rationale\n\nAfter PR #27, `ZZ` on a pathless document refuses, stays in the app, and the status line names the prompt. That is already better than improvise-8tk left it — the user is told where to go. The question is whether being told is enough, or whether the vim reflex should land somewhere useful by itself.\n\n## Alternatives Considered\n\n**Route `ZZ` into the prompt automatically.** Needs `CmdContext` to expose `file_path`, which it does not. improvise-2ud separately proposes that `CmdContext` borrow the ModelState/ViewState slices rather than copying ~20 fields, which would make this free — coordinate rather than adding a 21st copied field ahead of it.\n\n**Leave `ZZ` as it is.** The status line names `Ctrl-W`; the cost is one keystroke of discovery.\n\n**Constraint on any answer:** the prompt is SESSION-LOCAL and must stay in the command layer, never routed through `effect::Save`. Save is now a wire command (improvise-368h), and an effect that decides to prompt would open a prompt on a host nobody asked — gotcha 38's argument for keeping AcceptWizardModel off the wire.\n\n**Unanswered second question:** what `ZZ` means once the prompt is open. ZZ is save-AND-QUIT; if committing the path does not also quit, the reflex is only half honoured — but making the prompt's Enter mode-dependent is the hidden state improvise-ct5 just declined to add for `i`.\n","status":"open","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:32Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:45:42Z","labels":["area:tui"],"dependencies":[{"issue_id":"improvise-rw3a","depends_on_id":"improvise-2ud","type":"blocks","created_at":"2026-09-20T16:20:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-rw3a","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-g9jb","title":"The text-entry mode list is hand-written in two crates and checked against AppMode in neither","description":"Found by WU-G on PR #27 while adding AppMode::SavePrompt.\n\nThe set of text-entry modes is written down BY HAND IN TWO PLACES, in two crates:\n * improvise-command::keymap::tests::text_entry_modes_with_exits\n * improvise-wasm-client::keys::tests::text_entry_modes\n\nand NOTHING CHECKS EITHER AGAINST AppMode. So adding a text-entry variant compiles, passes, and is silently absent from both lists -- which matters more since PR #27, because the browser now refuses to ENTER a prompt it cannot commit (keys.rs::strands_the_user), and that refusal is driven off one of these hand-written lists.\n\nTHE FAILURE MODE IS THE QUIET ONE: a new prompt mode omitted from the wasm list is one a browser will happily open and then swallow the user's typing in -- doctrine(a_doorway_to_a_black_hole_is_worse_than_the_key), which PR #27 established against SavePrompt and found ExportPrompt had already.\n\nTHE FIX: derive the list once, from AppMode itself. `MinibufferConfig` is already the thing that makes a variant text-entry -- AppMode::minibuffer() is an exhaustive match returning Option\u003cMinibufferConfig\u003e (gotcha 11), so \"the text-entry modes\" is derivable rather than enumerable. A const or a function in improvise-protocol beside AppMode, with both crates reading it, replaces both lists.\n\nCHECK ONE THING BEFORE ASSUMING THE DERIVATION IS FREE: the wasm list and the command list may not be the same set for a reason -- SearchMode is hand-written on purpose and has no commit key (gotcha 11), and the browser's question (\"can this client commit here\") is not identical to the keymap's (\"does this mode take text\"). If they genuinely differ, the answer is two derived predicates over one enum, not two hand-written lists.\n\ndoctrine(a_hand_enumerated_mirror_needs_an_exhaustive_match) already covers this; this is an instance with no match behind it at all.\n","acceptance_criteria":"The set of text-entry modes is DERIVED from AppMode rather than hand-written twice. AppMode::minibuffer() is already an exhaustive match returning Option\u003cMinibufferConfig\u003e, so the set is derivable. Before assuming one derivation serves both: check whether the keymap's question ('does this mode take text') and the browser's question ('can this client commit here') are actually the same set — SearchMode is hand-written on purpose and has no commit key. If they differ, the answer is two derived predicates over one enum, never two hand-written lists.","status":"closed","priority":3,"issue_type":"task","assignee":"claude-agent-protocol","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:22Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:47:03Z","started_at":"2026-09-21T03:28:20Z","closed_at":"2026-09-21T03:47:03Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-z05r","title":"CommitExport does not bail on an empty path, and ExportCsv does not abort the batch","description":"Found by WU-G on PR #27 while fixing the save arms, and left deliberately: these are the same two defects one file over, at lower severity, and fixing them in that PR would have widened a save-focused change into an export-focused one.\n\nTWO ARMS, both mirroring what was just fixed for save:\n 1. CommitExport does not bail on an empty path, where CommitBuffer's three kinds all do (gotcha 20: buffer -\u003e trim -\u003e bail-on-empty -\u003e dirty -\u003e status is the shared spine).\n 2. ExportCsv does not set abort_effects on failure, where Save and (as of PR #27) SaveAs both do. So a `Binding::Sequence` or a `:` line composing an export with anything after it continues past a failed write.\n\nWHY IT IS LOWER SEVERITY THAN THE SAVE ARMS, and this is the honest reason it was deferred rather than a claim that it does not matter: an export failure discards nothing. improvise-8tk was a P1 because ZZ quit and the model was gone. Here the model is untouched and the user has simply not got the CSV they asked for -- so the cost is a missing file and a message, not lost work.\n\nNOTE THE SHAPE, because it is the third instance in this area: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; and this is the export arm of the same rule. doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) exists for exactly this, and the general fix may be better than the third patch -- consider whether \"an effect that writes a file and fails aborts the batch\" can be made structural rather than remembered three times.\n\nALSO CHECK, while in there: whether the export error is visible at all, given that ExportPrompt is a minibuffer mode and a status message written while a minibuffer is open is never drawn (see the sibling bead on draw_bottom_bar).\n\n## Steps to Reproduce\n\n1. `CommitExport` with an empty path: it does not bail, where `CommitBuffer`'s three kinds all do (gotcha 20's shared spine is buffer → trim → bail-on-empty → dirty → status).\n2. Compose an export with anything after it — a `Binding::Sequence`, or a `:` line on ` . ` — and make the write fail. `ExportCsv` does not set `abort_effects`, so the chain continues past the failure, where `Save` and (since PR #27) `SaveAs` both stop it.\n\nLower severity than the save arms because an export failure discards nothing: the model is untouched and the user simply has not got their CSV.\n","acceptance_criteria":"CommitExport bails on an empty path like the other three commit kinds, and ExportCsv aborts the batch on a failed write like Save and SaveAs. Prefer the structural fix over the third patch: this is the third instance of the same asymmetry, so consider whether 'an effect that writes a file and fails aborts the batch' can be made impossible to forget rather than remembered three times.","status":"closed","priority":3,"issue_type":"bug","assignee":"claude-agent-cmd","owner":"el-github@elangley.org","created_at":"2026-09-20T06:04:21Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:49Z","started_at":"2026-09-21T03:27:54Z","closed_at":"2026-09-21T04:43:49Z","labels":["area:tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-44ad","title":"ColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence","description":"Found while closing improvise-hkt6 (PR #20, WU-K), tried, and DELIBERATELY REVERTED rather than landed -- filed so it is a decision rather than a rediscovery.\n\nhkt6(b) obliged grid_updates to diff `totals` against the LABEL-FOLDED cache rather than against the mirror (see gotcha 33). The symmetric move for `col_widths` looks obviously right and is not free: it BREAKS `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nWHY, and it is a real property rather than a brittle assertion: folding the labels RE-FITS the widths (gotcha 33(c) -- every arm that touches labels or widths re-fits, because a renderer zips widths with labels and a length mismatch indexes off the end). So under a fold-relative diff the ColWidths that a reframe currently carries becomes REDUNDANT and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS the widths.\n\nTHE REASON IT WAS NOT SPENT IN PR #20: that assertion is gotcha 33(c)'s standing evidence -- it is the test that catches \"a late ColWidths put the mismatch straight back\". Rewriting it inside a PR whose subject was three totality gaps would have destroyed the evidence for a rule while fixing something else, which is exactly the shape improvise-q0vb was declined for on PR #12. Two right changes, different costs.\n\nTHE DECISION TO MAKE:\n * Leave widths diffed against the mirror. Costs one redundant ColWidths per reframe -- the same direction Dirty::of is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible and arguably correct on that principle alone.\n * Or move widths to the fold and REPLACE the socket assertion with one that still pins 33(c)'s property -- that a client never holds widths of a different length from its labels -- rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\nPREFER THE SECOND ONLY IF the replacement test is written first and shown to fail against a deliberately late ColWidths. Otherwise prefer the first and say so in gotcha 33, so the asymmetry between totals and widths is STATED rather than discovered by the next person who notices it.\n\n## Rationale\n\nimprovise-hkt6 obliged `grid_updates` to diff `totals` against the LABEL-FOLDED cache rather than the mirror, or a rename that leaves the number equal sends no `Totals` and the client blanks its own. The symmetric move for `col_widths` looks obviously right and is not free: it breaks `a_client_on_a_socket_is_seeded_and_gets_its_edit_back`.\n\nThat is a real property, not a brittle assertion. Folding the labels RE-FITS the widths (gotcha 33(c)), so under a fold-relative diff the `ColWidths` a reframe currently carries becomes redundant and is suppressed. The client is correct either way; what changes is whether the reframe still SENDS them.\n\n## Alternatives Considered\n\n**Leave widths diffed against the mirror.** Costs one redundant `ColWidths` per reframe — the same direction `Dirty::of` is deliberately sloppy in (gotcha 35: over-report pays one redundant Projection, under-report leaves a client wrong forever). Defensible on that principle alone, and then the asymmetry between totals and widths should be STATED in gotcha 33 rather than rediscovered.\n\n**Move widths to the fold and replace the socket assertion** with one that still pins 33(c)'s property — that a client never holds widths of a different length from its labels — rather than one that pins the message count. That is the better test either way, and writing it is most of the work.\n\n**Not acceptable: move widths to the fold and simply update the failing assertion.** It is gotcha 33(c)'s standing evidence, and rewriting it inside a change it is meant to guard destroys the evidence for that change — the improvise-q0vb / PR #12 shape.\n","status":"closed","priority":3,"issue_type":"decision","owner":"el-github@elangley.org","created_at":"2026-09-20T05:54:56Z","created_by":"Edward Langley","updated_at":"2026-09-21T12:39:38Z","closed_at":"2026-09-21T12:39:38Z","labels":["area:command"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -676,12 +811,12 @@ {"_type":"issue","id":"improvise-x7sw","title":"Which-key hints have no protocol home (the open half of improvise-jb3)","description":"improvise-jb3 closed with its WIDGET half already done -- WhichKeyWidget::new(\u0026[(String, \u0026static str)]) was never \u0026App-coupled. This is the half that was never that ticket.\n\nThe derivation happens in draw.rs: App::armed_prefix_keymap() -\u003e Keymap::binding_hints(). ViewState::transient_prefix IS portable (gotcha 13 stores a prefix as an ADDRESS, not a keymap, precisely so ViewState stays free of improvise-command types) -- but THE KEYMAP SET IS NOT ON THE CLIENT, so a browser which-key cannot be derived from ViewState alone.\n\nTWO ANSWERS, and the choice is the ticket:\n * THE RESOLVED HINT LIST CROSSES -- a Projection, or a ViewState field. Cheap, and it keeps the keymap a client-only concept, which is the stated direction (\"keys should be converted into commands in the browser: the keymap should not be known server side at all\"). But it puts a UI string list on the wire, which is what improvise-k099 is already unhappy about for AppMode::hint_text/label.\n * THE CLIENT GETS THE KEYMAPS. Consistent with keys being a client concept, and the wasm client already has keys.rs -- but the hint list is currently derived from the SERVER-side KeymapSet, so this means the client owns the keymaps outright and the server stops having them. Larger, and probably where this is heading anyway.\n\nNote the second reading is the one that fits improvise-bpq2. Decide them together.","acceptance_criteria":"A browser can draw which-key hints. Decide between the resolved hint list crossing as a Projection, and the client owning the keymaps outright — the second is where improvise-bpq2 already went (the table now lives in improvise-protocol), so prefer it unless there is a reason not to, and decide it TOGETHER with improvise-k099, which is the same question about UI strings on a wire enum. Whichever, ViewState::transient_prefix stays an ADDRESS and not a keymap (gotcha 13).","status":"closed","priority":3,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:05Z","started_at":"2026-09-20T17:53:39Z","closed_at":"2026-09-20T18:18:05Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-2yc2","title":"DataStore::live_records walks a HashMap -- decide its order before writing the caller","description":"The next instance of improvise-shyt, found while fixing it, and deliberately LEFT ALONE rather than guessed at.\n\nDataStore::live_records walks the records: HashMap, so it yields a different order in every process -- the same defect that made records-mode rows reorder between sessions, misdirected drill staged edits, and drifted SUM/AVG in the last f64 bits.\n\nIt differs from shyt in one way that matters: IT HAS NO CALLERS YET. So this is not a bug report, it is \"decide the order deliberately before something depends on it\". The ADR 002 surrogate path will be the caller -- the RecordHash -\u003e RecordEntry index is what live_records walks -- and whoever writes it should pick an order on purpose rather than inherit a hash walk and then discover it, which is how shyt happened.\n\nTHE ANSWER SHYT LANDED ON, for reference: the index narrows, the primary IndexMap orders (matching_positions resolves each candidate with get_index_of, sorts, reads back with get_index). Measured at 18,000 cells that cost at worst +20% on one path and was 7% FASTER on the other. Whether that is the right answer here depends on what the surrogate path wants; a hash-keyed index may legitimately want no order at all, in which case say so and return something whose type admits it.","status":"closed","priority":3,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:45:22Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:54:43Z","started_at":"2026-09-20T05:32:37Z","closed_at":"2026-09-20T05:54:43Z","close_reason":"Closed by PR #20 (WU-K). DECIDED: live_records answers in the STORE'S OWN ORDER -- it walks the primary IndexMap's cells and dedupes by RecordHash, so a record sits where its FIRST cell sits. That is the order matching_positions already gives (gotcha 18), projected onto records, rather than a second answer to \"what order is this store in\".\n\nDerived on each call rather than memoised on RecordEntry, because a stored position is a copy and the tombstone a deleted record leaves would keep it while its cells moved to the end. That case is now tested (a_recreated_record_takes_its_new_place_not_its_old_one) -- and it was found because Copilot noticed the doc comment ASSERTED the derive-don't-memoise choice mattered for delete-and-recreate while nothing tested it; a memoising implementation passed all three other tests.\n\nTHE TICKET'S ALTERNATIVE WAS REJECTED WITH A REASON WORTH KEEPING: \"a hash-keyed index may want no order at all -- return something whose type admits it\" cannot be built. Every iterator has an order, and a type that merely DISCLAIMS one still hands the caller hash order. Disclaiming is not making unrepresentable.\n\nCost moves O(records) -\u003e O(cells). With no caller to benchmark, correctness is the defensible default and the doc says what a hot-path caller should bring.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-odbc","title":"[bug] bank-info.improv in the repo root does not parse","description":"Incidental finding from improvise-cr3 (2026-09-19), outside that ticket scope, unreproduced beyond the one load.\n\n Parse error at line 293, column 3: - JORDAN MARTINEZ (expected pipe_quoted)\n\nTWO READINGS, and they want different actions:\n * The file is STALE -- written before a grammar change -- in which case delete it or regenerate it, and the finding is that a sample file in the repo root is not exercised by anything.\n * The GRAMMAR HAS A GAP -- some name shape a writer emits that the reader rejects. That is the serious reading, because save/load round-trip is a property test (persistence proptests, parse(format(parse(generate())))) and a gap that survives it is a gap the GENERATOR does not reach.\n\nDecide which by checking whether improvise can WRITE that line today. If it can, the round-trip property is incomplete and the generator is the thing to fix, not the file.","status":"closed","priority":3,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:37:30Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:58Z","started_at":"2026-09-20T05:32:40Z","closed_at":"2026-09-20T06:28:58Z","close_reason":"Closed by PR #22 (WU-L). THE TICKET'S PREMISE WAS FALSE, AND TESTING IT RATHER THAN ASSUMING EITHER READING IS WHAT MADE THIS CHEAP.\n\nThe ticket offered two readings and hoped for the serious one: that the grammar had a gap some writer could emit, which would mean the round-trip property is incomplete and the GENERATOR is the thing to fix. It said to decide by checking whether improvise can WRITE the offending line. IT CANNOT. item_list's name is `pipe_quoted | bare_name`, bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space, and quote_name mirrors it character for character -- so improvise writes |JORDAN MARTINEZ|. Reader and writer agree, the round-trip property is COMPLETE, and the grammar-walking generator needs no change.\n\nThe file was stale BY HAND: c48a5cd migrated it to v2025-04-09 by editing in place, pipe-quoting the Date items and not the Entity ones, and every line of its ## Data section names a category `Measure` where the file declares `_Measure`. Unloadable since April. Nothing loaded it.\n\nDELETED RATHER THAN REGENERATED, on the user's ruling 2026-09-19, together with foo.improv (the same class, one file over, with no version line at all). There is no generator for either -- they are hand-written fake data -- so regenerating meant inventing a canonical form and checking in 1200 lines whose only property is that they parse, which the grammar proptests already establish over a far wider input space.\n\nTHE EVIDENCE KEPT IS WORTH MORE THAN THE FILE:\n * an_item_name_the_item_line_cannot_carry_round_trips -- the deleted file's own names through format_md/parse_md.\n * a_bare_item_name_with_a_space_is_rejected -- the reader really refuses the unquoted line, with the quoted form parsing as a precondition. Without this the first test is consistent with BOTH spellings being accepted.\n * tests/shipped_documents_load.rs -- a DIRECTORY WALK over the two directories .gitignore un-ignores, not a list of names, because a list is a second place to remember and the forgotten file is the one nobody added to it. Fault injected and failed. Then HARDENED: it matched Path::extension() == \"improv\", which of x.improv.gz is \"gz\", so a gzipped shipped document would have been skipped -- the very hole the test exists to close, put back inside the fix for it.\n\nTHE FINDING, which is the opposite of what the ticket expected: a checked-in document in a format the repo parses is exercised by NOTHING unless something loads it, and grammar proptests cannot see it because they GENERATE their inputs.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"REWRITTEN 2026-09-19 to match context/adr/003-live-backed-models.md, and DEFERRED by user ruling in the same session, together with improvise-rucf and improvise-mjy7. The ADR calls this \"worth doing first regardless of the eventual shape\"; the user's ruling is that the whole direction waits, so it waits as a unit rather than landing a seam with nothing on the other side of it.\n\nTHE WORK IS UNCHANGED: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies and the existing tests must carry across unchanged in meaning.\n\nTODAY Model holds a DataStore directly and a Workbook loads ENTIRE; there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam, it is an assumption spread across GridLayout, evaluation and persistence.\n\nWHAT ADR 003 ADDS TO THE ORIGINAL FRAMING, and it is the part worth re-reading before starting:\n\n * AGGREGATION IS THE INTERFACE, AND IT IS ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set -- exactly SQL's aggregates -- and GridLayout asks for a VALUE, not for the rows behind it. Shape the trait around \"give me the aggregate for this coordinate set\", never around \"give me the cells\": shaping it around cells forecloses pushdown, which is the entire point of the eventual live source.\n\n * WRITES MUST NOT BE IN THE TRAIT. This is new since the original text and it is a real constraint. If writing were in the trait a query source implements, a read-only source would have to FAIL AT RUNTIME for a method it structurally cannot serve. Keep reads and aggregates in the trait every source implements; let writing be a capability only the owned in-RAM implementation has. Read-only then becomes UNREPRESENTABLE rather than enforced -- the same discipline as CellId (ADR 002) and doctrine(an_invariant_is_made_unrepresentable_not_validated).\n\n * FORMULAS DO NOT MOVE. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore, so the split is clean: raw data is queried, derived data stays in RAM.\n\nCONSTRAINT: forbidden(improvise_core, io). The TRAIT belongs in improvise-core; any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWHY IT IS STILL WORTH DOING ON ITS OWN WHEN IT IS UN-DEFERRED: it is the seam every later option needs -- live-backing, a test double, or the import-once path ADR 003 rejected as the ONLY answer but did not forbid as A path; it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate; and done as a pure refactor it is cheap and reviewable, where doing it AS PART OF adding a SQLite source mixes a large refactor with a large feature and makes both unreviewable.\n","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-qddo","title":"Make DataStore an implementation of a source trait, not the implementation","description":"REWRITTEN 2026-09-19 to match context/adr/003-live-backed-models.md, and DEFERRED by user ruling in the same session, together with improvise-rucf and improvise-mjy7. The ADR calls this \"worth doing first regardless of the eventual shape\"; the user's ruling is that the whole direction waits, so it waits as a unit rather than landing a seam with nothing on the other side of it.\n\nTHE WORK IS UNCHANGED: introduce a source trait in improvise-core and make the current in-RAM DataStore AN implementation of it rather than THE implementation. No behaviour change -- doctrine(characterization_survives_the_move) applies and the existing tests must carry across unchanged in meaning.\n\nTODAY Model holds a DataStore directly and a Workbook loads ENTIRE; there is no partial-load path anywhere in the tree (verified while declining improvise-9ix). So \"where cell values come from\" is not a seam, it is an assumption spread across GridLayout, evaluation and persistence.\n\nWHAT ADR 003 ADDS TO THE ORIGINAL FRAMING, and it is the part worth re-reading before starting:\n\n * AGGREGATION IS THE INTERFACE, AND IT IS ALREADY SQL. apply_agg computes Sum/Avg/Min/Max/Count over a matching cell set -- exactly SQL's aggregates -- and GridLayout asks for a VALUE, not for the rows behind it. Shape the trait around \"give me the aggregate for this coordinate set\", never around \"give me the cells\": shaping it around cells forecloses pushdown, which is the entire point of the eventual live source.\n\n * WRITES MUST NOT BE IN THE TRAIT. This is new since the original text and it is a real constraint. If writing were in the trait a query source implements, a read-only source would have to FAIL AT RUNTIME for a method it structurally cannot serve. Keep reads and aggregates in the trait every source implements; let writing be a capability only the owned in-RAM implementation has. Read-only then becomes UNREPRESENTABLE rather than enforced -- the same discipline as CellId (ADR 002) and doctrine(an_invariant_is_made_unrepresentable_not_validated).\n\n * FORMULAS DO NOT MOVE. Evaluation is fixed-point and derived values live in Model::formula_cache, not in DataStore, so the split is clean: raw data is queried, derived data stays in RAM.\n\nCONSTRAINT: forbidden(improvise_core, io). The TRAIT belongs in improvise-core; any IO-touching implementation belongs in improvise-io. Do not let the trait signature drag IO types into core; if it needs Result, that is a boundary decision worth arguing rather than defaulting (AGENTS.md keeps Option/Result at the edges).\n\nWHY IT IS STILL WORTH DOING ON ITS OWN WHEN IT IS UN-DEFERRED: it is the seam every later option needs -- live-backing, a test double, or the import-once path ADR 003 rejected as the ONLY answer but did not forbid as A path; it makes \"the model is fully in RAM\" an explicit property of one implementation rather than an invisible assumption of the whole crate; and done as a pure refactor it is cheap and reviewable, where doing it AS PART OF adding a SQLite source mixes a large refactor with a large feature and makes both unreviewable.\n","notes":"\n--- READ-ONLY DECIDED (user, 2026-09-19), and it shapes the trait ---\nA live-backed model is READ-ONLY, and not reluctantly: for many use cases\nediting capabilities would be a DOWNSIDE. Pointing a pivot tool at a production\ndatabase and being unable to write to it is a feature.\n\nSO DO NOT PUT WRITES IN THE TRAIT. If writing were a trait method, a read-only\nsource would have to fail at RUNTIME for something it structurally cannot\nserve. Keep reads -- and crucially AGGREGATES, see the note above about shaping\nit around \"give me the aggregate for this coordinate set\" -- in the trait every\nsource implements, and let writing be a capability only the owned in-RAM\nimplementation has. Then read-only is UNREPRESENTABLE rather than enforced,\nwhich is the same discipline ADR 002 CellId uses and the same design-principles\n3 rule.\n\nRELATED CONVERGENCE WORTH KNOWING: ADR 002 already makes SetCell take a\nCellId::Record and nothing else, so a live-backed model that projects no Record\nids makes a client-side write unconstructible with no runtime check and no\n\"this model is read-only\" dialog. The settability distinction built for\naggregates carries read-only sources for free -- so the client half of this may\nalready be done.","status":"deferred","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T00:16:29Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:47Z","dependencies":[{"issue_id":"improvise-qddo","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-25T01:04:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-i9lt","title":"View::axis_of panics for a category the view has not seen","description":"Noticed during improvise-35e (2026-09-19), reported not fixed per the mention-do-not-fix rule for pre-existing issues.\n\nView::axis_of(cat) panics for a category the view has never seen, and View::tileable_cats admits exactly such categories -- it filters on try_axis_of(c).is_none_or(...). So the pairing is a live panic waiting for the right view state.\n\nThe old TileBar called axis_of over that list. The migrated widget cannot panic there any more, because tile_entries uses try_axis_of(...).unwrap_or(Axis::None) -- so the reachable path is gone and no failing test was written, since the old path no longer exists to fail.\n\nWorth a guard anyway: either make axis_of total (returning Axis::None like tile_entries does) or make it private and force callers through try_axis_of. A panicking accessor over a list built to include the panicking case is a trap for the next caller, and the next caller will not have the migration to protect them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T23:40:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:25:57Z","closed_at":"2026-09-20T00:25:57Z","close_reason":"The reachable path is gone. improvise-edp migrated CategoryContent to take (\u0026CategoryTreeCache, \u0026HashSet\u003cString\u003e) instead of \u0026View, and the cache is built with try_axis_of(...).unwrap_or(Axis::None) -- so the widget that called view.axis_of(name) over a list that admits unseen categories no longer exists. Same resolution as the TileBar path in improvise-35e.\n\nView::axis_of itself is unchanged and still panics for a category the view has not seen. Left deliberately: it is a pre-existing API sharpness with no remaining caller that can reach it, and the mention-do-not-fix rule applies. If it acquires a new caller, make it total (returning Axis::None like the cache does) or make it private and force callers through try_axis_of -- a panicking accessor over a list built to include the panicking case is a trap for whoever arrives next.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-j31x","title":"Server hosts several documents: a registry above Sessions","description":"DEFERRED by the user (2026-09-19) -- recorded so nothing forecloses it, not to be built now.\n\nimprovise is a multi-document app and the server should be able to host several separate models. The shape:\n server -\u003e many documents\n document -\u003e one App + one Sessions + many ClientSessions\nThat is NOT one App per session (what improvise-q08 ticket text says, written before the session model landed) and NOT one App total.\n\nNOTHING THAT HAS LANDED NEEDS TO CHANGE. Sessions does not own the App -- connect(\u0026mut self, app: \u0026App, ..), apply(\u0026mut self, app: \u0026mut App, ..) and project_all(\u0026mut self, app: \u0026App, ..) all take it as a parameter. So Sessions is already document-agnostic and a Sessions instance is implicitly bound to one document generation sequence, which is right: document_generation lives on the App. Multi-document is a REGISTRY ABOVE Sessions mapping a document id to its (App, Sessions) pair.\n\nWHY IT MATTERS BEYOND TIDINESS: sessions editing independent models are trivially non-conflicting, so ADR 001 concurrency design scopes per document FOR FREE -- the basis version tiers live on the App and each document has its own. A server that shared one App across documents would throw that away.\n\nWHAT TO AVOID IN THE MEANTIME: improvise-q08 must not hard-code a single App into a static or a lazily-initialised singleton. The document should be a PARAMETER even while there is only one, and a connection should be attachable to a document even if the MVP answers that from a CLI argument naming one .improv file.\n\nScope when picked up: the registry, document lifecycle (open/close/last-session-leaves), and how a client names the document it wants. Auth and access control are explicitly NOT in scope here -- file them separately if wanted.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:32:31Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:33:15Z","closed_at":"2026-09-20T00:33:15Z","close_reason":"Registry landed in crates/improvise-ws-server/src/registry.rs. 1051 tests pass.\n\nIT CAUGHT A REAL BUG BY VERIFYING THE SEAM INSTEAD OF TRUSTING IT. My brief and the q08 notes both said nothing below the registry needs to change. Nearly true -- but Sessions::next_id is a PER-Sessions counter (confirmed: project.rs holds next_id on Sessions, not globally), so EVERY documents first client is SessionId(0), and transport.rs keyed its outs: HashMap\u003cSessionId, _\u003e on it. A registry that kept that key would have delivered document Bs frames to document As client the moment two documents each had one client.\n\nThe server-wide address is the pair, ClientId { document, session }. Registry::route(document, Vec\u003cAddressed\u003e) is the ONLY place a SessionId acquires a document -- the one it came out of, since that is the only one in scope there -- so cross-document delivery of a projection, a SwitchView broadcast (gotcha 36) or an Applied::Rebased fan-out (gotcha 39) is UNREPRESENTABLE rather than checked. The test names the hazard: an_edit_is_projected_only_to_the_edited_documents_clients asserts on_alpha.session == on_beta.session FIRST.\n\nLIFECYCLE, and the non-obvious half. Open at STARTUP from the files named on the command line -- open-on-request was rejected because deciding which paths a client may open IS access control, which this ticket put out of scope; serving only what was named leaves the question unanswered rather than answered badly. NOT closed when the last session leaves: eviction loses unsaved edits, and worse, document_generation and record_versions start at ZERO in a fresh App (gotcha 40), so a client reconnecting with a STALE basis would have it accepted as current -- a hole in the exact mechanism ADR 001 is building, bought for a memory saving nobody asked for. Registry::is_idle is the hook a future policy asks from; nothing evicts.\n\nA client names its document in the URL PATH (ws://host:port/quarterly), which leaves wire.rs COMPLETELY unchanged so the wasm-client frames keep working; / resolves to the only document when there is exactly one, and with two is ConnectError::Ambiguous rather than a coin toss. A refusal is a websocket CLOSE FRAME carrying the reason -- no ServerMsg::Error variant invented, because that is a connection fact and wire.rs only grows variants the mutations demand.\n\nEvery required claim has a counter-test beside it so it cannot pass vacuously -- e.g. the broadcast-isolation fixture gives BOTH documents a view named Second, so betas silence is the routing and not a name it would have rejected anyway.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2wi8","title":"ADR-001 e: a forced overwrite is visible to the client it overwrote","description":"ADR 001 layer 1, step e. Depends on step c.\n\nWithout this, force is silent data loss with a confirmation step in front of it. The overwritten client edit was already committed, so this is a NOTIFICATION rather than a conflict -- but it must exist, or \"someone else won\" is indistinguishable from \"my edit never happened\".\n\nDesign note: the notification is about the loser OWN prior edit, so it is per-client and therefore UI -- the model is shared, the UI is not. Do not broadcast it as document state.","acceptance_criteria":"A client whose committed edit was overwritten by a force is TOLD. Without this, force is silent data loss with a confirmation step in front of it — 'someone else won' must be distinguishable from 'my edit never happened'. It is a NOTIFICATION rather than a conflict, because the overwritten edit was already committed. Per-client and therefore UI: do not broadcast it as document state (model-shared-ui-not-shared).","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:34Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-2wi8","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:47Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-2wi8","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vb6l","title":"ADR-001 d: when an intent cannot be re-composed, show what changed","description":"ADR 001 layer 1, step d -- the expensive one, and the honest cost of this design. Depends on step c.\n\nConflict class 2 (shape change vs cell edit) has NOTHING COHERENT TO FORCE: the key names a coordinate that no longer exists, and applying anyway writes a CellKey naming a deleted category -- the ghost-coordinate corruption the whole ADR exists to prevent.\n\nSo for class 2 the UI owes the user WHAT CHANGED plus the concrete alternatives -- re-target the edit, or re-create the structure first -- rather than a bare refusal or a force button that cannot be honoured.\n\nFORCE NEVER CREATES STRUCTURE: re-instating a category someone deleted so an edit can land is a different and LARGER intent than the user expressed. It must be an explicit action, never a side effect of forcing a cell edit.\n\nClass 3 (a formula now shadows the value) is forceable WITH A WARNING: the write lands, the display stays derived.\n\nNOTE FOR WHOEVER PICKS THIS UP: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention -- e.g. a category deletion being explicit about the cells it destroys -- rather than making the conflict cheaper to resolve. That is a product call, not an implementation detail.","acceptance_criteria":"For conflict class 2 the user is shown WHAT CHANGED plus the concrete alternatives — re-target the edit, or re-create the structure first — rather than a bare refusal or a force button that cannot be honoured. FORCE NEVER CREATES STRUCTURE: re-instating a deleted category so an edit can land is a larger intent than the user expressed and must be an explicit action. Class 3 (a formula now shadows the value) is forceable WITH A WARNING. AND THE PRODUCT CALL COMES FIRST: the ADR says plainly that if this dialogue is more than we want to build, the fallback is making shape changes rarer under contention — e.g. a category deletion being explicit about the cells it destroys — rather than making the conflict cheaper to resolve. Answer that before building.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:27:33Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:22Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-vb6l","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:46Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-vb6l","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:56Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-7b8v","title":"Concurrent edits: advisory reservations (ADR 001, layer 2 — UX only)","description":"Implements layer 2 of context/adr/001-concurrent-edits.md. BLOCKED ON layer 1 by design, not by dependency: correctness must not rest on this layer.\n\nGeneralise the drill staging mechanism so a client publishes the KEYS it has staged, letting other clients grey them out and warn before someone types into a cell that is about to be taken. The VALUES stay private -- the user ruled on that in improvise-tom1: staged edits are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState.\n\nTHE DESIGN CONSTRAINT: this layer is ADVISORY ONLY. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to \"you find out at commit\" -- never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is exactly why the basis check must land first and stand alone.\n\nNote the split it implies: the reservation KEY SET is shared (everyone must see it to be warned) while the VALUE is private. That maps onto the existing EffectKind distinction rather than fighting it.\n\nDo not add lease renewal or lock timeouts as correctness machinery. If a reservation outlives its client, the worst outcome is a stale warning, and the basis check still rejects the actual conflict.","acceptance_criteria":"A client publishes the KEYS it has staged; other clients grey them out and warn before typing into a cell about to be taken. THE VALUES STAY PRIVATE — the user ruled on that in improvise-tom1. ADVISORY ONLY: if a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to 'you find out at commit' and NEVER to corruption — which is what makes it safe to be sloppy about timeouts and is why the basis check must land first and stand alone. Do NOT add lease renewal or lock timeouts as correctness machinery.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:09:54Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:56:23Z","labels":["area:protocol"],"dependencies":[{"issue_id":"improvise-7b8v","depends_on_id":"improvise-3al1","type":"blocks","created_at":"2026-09-19T23:27:48Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-7b8v","depends_on_id":"improvise-5krd","type":"parent-child","created_at":"2026-09-25T01:04:57Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ry89","title":"cv_regress/cv_classify with k=1 now return plausible numbers instead of NaN","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. A signal loss, not a wrong answer.\n\nmean_over(total, 0) == 0.0 replaced a 0.0/0.0 NaN, which is right everywhere it is used -- the reviewer looked for a case where an empty set WINS a comparison it should lose and found none (inside fit every candidate scores the same rows; cv_* pool pairs rather than reduce to a per-fold metric).\n\nBut with k=1, folds(n,1) puts every index in the single fold, so the training complement is EMPTY and the model trains on nothing. Pre-change, standardize(\u0026[]) gave mean = NaN and every pooled prediction was NaN -- the degenerate call announced itself. Post-change:\n k=1 pooled[0] = (1.48e-6, 8.0), nan_predictions = 0\nSo a library caller passing k=1 gets 37 near-zero cross-validated predictions from a tree fit on nothing, and anyone computing R2 off them gets a number rather than a NaN.\n\nexamples/fit_csv.rs guards with k \u003e= 2, so no shipped path hits it. Fix by documenting on cv_regress/cv_classify that k\u003c2 is meaningless, or by returning empty for k\u003c2. Note the folds doc argues max(1) is \"the answer the shape gives\" -- true for folds, and no longer true for the cross-validators.\n\nNIT from the same review: standardize_is_bit_for_bit_what_it_was is insensitive -- both perturbations of standardize left it green (one was caught by cv_regress instead). Its four witnesses do not separate accumulation orders on the 37-row fixture. Worth strengthening while here.","notes":"\n--- PARTIALLY ADDRESSED on PR #11 (2026-09-19) ---\nCopilot independently re-found this. The PR agent DOCUMENTED it rather than\nguarding it, and the reasoning is right: returning empty for k \u003c 2 is a\nbehaviour change to the exact functions scoring_known_answers.rs pins\nbit-for-bit, inside a PR whose entire claim is bit-identity. It deserves its\nown commit where it is the headline rather than a rider on a reshape.\n\nSTILL OPEN: decide guard vs document-only. If guarding, the known-answer\nvectors must be re-established afterwards, not assumed -- that is the whole\npoint of them.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:04Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"GUARDED, not documented. The returned value is pooled predictions from trees that never saw the row; with one fold there are none, so EMPTY IS THE TRUE COUNT, not a sentinel. Documenting would leave a library function whose degenerate answer looks like a models output.\n\nThe asymmetry is resolved by splitting the QUESTION, not by clamping differently: folds keeps its max(1) (a partition into one piece is still a partition) and gains a note that the answer is its alone; a new private cv_folds(n,k,rng) yields no fold below k=2 and is what both cross-validators ask. Declining BEFORE the shuffle costs the caller generator nothing.\n\nTHE TICKET PREMISE WAS FALSE and was checked rather than assumed: it said returning empty for k\u003c2 would change numbers the known-answer vectors pin bit-for-bit. Those vectors only ever call k=4 and k=3, so the guard changes none of them -- all six pass unchanged, not one pinned number edited.\n\nThe insensitive-witness NIT is fixed too: standardize_is_bit_for_bit_what_it_was pinned four numbers and let r.target/std - mean/std (same algebra, different rounding) through AND past both CV vectors; it now pins all 37 normalized targets, which that injection moves 21 of.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k2i4","title":"The two nullary model commands are unaddressed and act on one client's staged state","description":"Wave-4 review (2026-09-19), CONFIRMED, LOW. Worth a decision and a note rather than an immediate change, since multi-client is not wired.\n\nApplyAndClearDrill and AcceptWizardModel are safe no-ops when nothing is staged (both early-return), so a client cannot corrupt the document by sending them blind. Two residual issues:\n\n(a) SPURIOUS PROJECTIONS. Dirty comes from the EffectKind, not from whether anything happened, so a no-op still dirties everything at that level and reaches every client. Measured: ApplyAndClearDrill dirties grid+category_tree+formulas; AcceptWizardModel dirties those plus view_list+tile_bar, emitting a non-empty projection list for a no-op.\n\n(b) THE STAGED STATE IS GLOBAL AND THE COMMAND IS NOT ADDRESSED. Sessions::apply routes Command::Model with no SessionId, while drill_state and wizard live on the single authoritative App. So client B sending Model(ApplyAndClearDrill) commits client A uncommitted staged edits, and Model(AcceptWizardModel) installs the workbook A half-finished wizard built -- replacing the document under everyone.\n\nThat collides with the user ruling on improvise-tom1: staged drill edits belong to whoever staged them and must not be visible to others. Committing them on someone else behalf is the stronger version of the same violation. Gotcha 30 calls this state session-local residue; the command layer now lets an unaddressed message act on it.\n\nDECIDE: either these commands become addressed (a SessionId, and the effect acts only on that session staged state -- which means the staging map moves off the single App), or they are removed from the wire vocabulary and stay key-path only. The second is cheaper and consistent with tom1.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T22:18:03Z","created_by":"Edward Langley","updated_at":"2026-09-20T00:58:04Z","closed_at":"2026-09-20T00:58:04Z","close_reason":"Stale -- already resolved by improvise-3x3u, verified during improvise-fkfg. 3x3u removed ApplyAndClearDrill and AcceptWizardModel from ModelCommand entirely; only prose mentions remain in reduce.rs, no variants. So the premise of this ticket (two nullary wire variants acting on one client's staged state) no longer holds: those commands have NO WIRE FORM to carry a basis or a SessionId.\n\nConsequences, both checked while landing ADR 001 layer 1: layer 1 does not make this worse, and layer 1 LANDS HONESTLY WITHOUT IT. What survives is the successor 3x3u itself names -- multi-client drill wants the ADDRESSED form of those two commands, which is ADR 001 layer 2 work (improvise-tom1 and the reservations ticket), not this.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ls3y","title":"[bug] A wire-driven SwitchView does not push the view history; the effect does","description":"Found during improvise-kmgs (2026-09-19), pre-existing, out of that ticket scope.\n\napply_command SwitchView arm calls app.switch_to_view(name) DIRECTLY, while effect::SwitchView first pushes a ViewFrame onto view_back_stack and clears the forward stack. So a switch driven over the wire is NOT undoable by ViewBack, and a key-driven one is -- two spellings of one user action that differ in whether history records it.\n\nSurfaced while building a ViewBack session test: the test had to walk forward through the EFFECT to fill the stack, because the command path never fills it. That asymmetry is the tell.\n\nDecide which is right rather than blindly making them agree: history is arguably UI state (the model is shared, the UI is not), in which case the per-client stack should be fed by whichever door the client used -- and then the command path is the one that is wrong. Note improvise-jytm will make active_view per-client, which touches the same ground.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T22:11:30Z","created_by":"Edward Langley","updated_at":"2026-09-19T22:50:02Z","closed_at":"2026-09-19T22:50:02Z","close_reason":"Found again by Copilot on PR #11 and fixed in ee767f9, merged as eb0c271. apply_command performed Command::SwitchView itself, calling App::switch_to_view directly instead of running effect::SwitchView -- and the EFFECT is the only thing that pushes onto view_back_stack and clears view_forward_stack. It was also the last hand-rolled mutation left in apply_command, which sat badly against the PR own \"effects are the only mutation site\" thesis.\n\nWAVE 4 IS WHAT MADE IT BITE: ModelCommand::ViewBack pops exactly that stack, so a wire client could switch views and then be unable to go back, while the same two keypresses worked. Before ModelCommand there was no wire way to ask, so the missing push was invisible.\n\nDemonstrated first: a_wire_view_switch_records_history_the_way_a_key_switch_does failed with the document still on Second. Nothing else in the suite could see it -- switch_view_moves_the_document_and_the_client_together checks where a switch LANDED, not what it RECORDED.\n\nPart of Copilot suggestion was declined with reasons on the thread: the arm still returns a hand-written Dirty rather than Dirty::of(Model(LayoutGeometry)), because Dirty::of is a per-KIND over-approximation and a view switch is the one member of that kind which leaves the view list alone -- which view is ACTIVE is not in ViewListCache at all. switching_views_projects_the_axis_widgets_only pins that.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -720,7 +855,7 @@ {"_type":"issue","id":"improvise-o1c","title":"Import wizard has no way back; Esc discards all field decisions silently","description":"Every browsing step binds Esc to close-the-wizard, and no step binds a back key. A user who reaches ConfigureDates or NameModel and wants to change a field kind must Esc (losing every proposal toggle, formula, and name they entered) and restart the import. This is sharpest with the category-limit guard: advance() refuses to leave ReviewProposals/ConfigureDates telling the user to fix their proposals, but from ConfigureDates there is no key that returns to ReviewProposals. Suggest binding a back key (e.g. Backspace or Left) to a WizardStep::previous(), and/or requiring a confirm on Esc once proposals have been edited. Noticed during improvise-2wm.","acceptance_criteria":"A user can move backwards through wizard steps without losing entered state; Esc's destructiveness is either confirmed or reversible.","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:42Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-o1c","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-c6j","title":"WizardStep::Done is unreachable from the UI","description":"ImportWizard::advance() maps NameModel -\u003e Done, but the key layer never calls advance() on NameModel: Enter there commits the build (BuildWizardModel), which closes the wizard outright. So Done is only reachable from wizard.rs unit tests calling advance() directly. Either drop the variant and its close-on-any-key branch in command/cmd/wizard.rs, or route the successful build through Done so there is a real confirmation step. Noticed while writing characterization tests for improvise-2wm — the test for Done has to force the step by hand.","acceptance_criteria":"Either WizardStep::Done is removed (with its key branch and advance() arm) or a UI path reaches it; no state in the enum is unreachable.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T07:21:37Z","created_by":"Edward Langley","updated_at":"2026-08-14T01:10:30Z","closed_at":"2026-08-14T01:10:30Z","dependencies":[{"issue_id":"improvise-c6j","depends_on_id":"improvise-907","type":"supersedes","created_at":"2026-08-13T18:10:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ych","title":"Null category values become the literal item \"null\" on import","description":"ImportPipeline::build_model_with_warnings resolves a category value as v.as_str() or else v.to_string(). A JSON null therefore yields the item name \"null\" rather than being treated as missing. The Label branch a few lines below explicitly maps null to \"\", so the two paths disagree on what a null means. Noticed while implementing improvise-5j9 (skipped-record warnings).","acceptance_criteria":"A record with a null category value is either skipped-and-counted (like an absent key) or normalized consistently with the Label path; test covers the chosen behavior.","status":"closed","priority":3,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-07-31T06:02:57Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:33:47Z","closed_at":"2026-09-19T20:33:47Z","close_reason":"Fixed via a single scalar_name(\u0026Value) -\u003e Option\u003cString\u003e helper used by both the Category and Label branches. Value::Null is absence (csv_parser already maps a blank CSV field to Null) so the record is skipped and counted in the existing improvise-5j9 warning; the literal string 'null' stays a real item; numbers/booleans still stringify. Two failing tests written first demonstrated the bug, plus two pinning tests that must not break. NOTE beyond the ticket's letter: empty/whitespace-only strings are now also treated as missing, so JSON '' and a blank CSV field behave alike; revert by dropping the .trim().is_empty() clause in scalar_name and the distinct_values filter. wasm check clean.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2ud","title":"CmdContext copies individual fields instead of borrowing ModelState/ViewState slices","description":"crates/improvise-command/src/command/cmd/core.rs:15-56 + crates/improvise-command/src/ui/app.rs cmd_context(): after the ModelState/ViewState split (improvise-x2c/ew0), CmdContext still copies ~20 individual fields (dirty, search_query, panel flags/cursors, nav stacks) out of the slices. Follow-up: have CmdContext borrow \u0026ModelState/\u0026ViewState (or narrowed sub-views) so new fields don't require threading through cmd_context() and there is one source of truth. Also: display_value is computed eagerly on every context build (app.rs ~388-406) even when the command never reads it — consider lazy/on-demand. Complements improvise-drg (narrow Effect::apply signatures). Found in systematic review 2026-06-09.","acceptance_criteria":"CmdContext borrows \u0026ModelState/\u0026ViewState (or narrowed sub-views) instead of copying ~20 individual fields, so a new field does not have to be threaded through cmd_context(). display_value stops being computed eagerly on every context build when no command reads it. Note improvise-rw3a is waiting on this: it needs file_path on the context and should not add a 21st copied field ahead of the refactor.","notes":"SIZED, NOT DONE (wave 2, 2026-09-20). Attempted and reverted rather than\nhalf-landed. The bead reads as a cheap cleanup and is not one; here are the\ncounts, so the next attempt is planned rather than discovered.\n\nMEASURED SCOPE\n- 104 `ctx.\u003cfield\u003e` reads across 14 files (panel.rs 21, commit.rs 16,\n registry.rs 15, text_buffer.rs 13, search.rs 8, grid.rs 7, mode.rs 6,\n keymap.rs 4, navigation.rs 4, tile.rs 3, cell.rs 2, core.rs 2, wizard.rs 1,\n and 2 in improvise-wasm-client/src/render.rs).\n- 46 sites that MUTATE a ctx field in place to set a scenario up\n (`ctx.buffers = \u0026bufs;`, `ctx.mode = \u0026item_add_mode;`, ...). These cannot be\n rewritten mechanically: once CmdContext borrows the slice, the ViewState has\n to be built and mutated BEFORE the context exists, so each is a per-test\n restructure.\n- 99 `make_ctx` call sites, whose helper has to change shape to own or take a\n \u0026ViewState. That reaches essentially every command-layer test.\n\nDESIGN THAT WAS REACHED (worth keeping, it is most of the work)\n CmdContext { model, workbook, view, layout, registry,\n view_state: \u0026ViewState, model_state: \u0026ModelState,\n drill: Option\u003c\u0026DrillState\u003e, wizard, visible_rows, visible_cols,\n key_code }\nEach removed field is read at its one home (`ctx.view_state.mode`,\n`ctx.model_state.dirty`) rather than through an accessor, because an accessor\nbeside the field is a second answer to the same question. `has_drill_state: bool`\nbecomes `drill: Option\u003c\u0026DrillState\u003e` -- strictly more information, and it is what\nmakes the laziness possible.\n\nDISPLAY_VALUE: the mechanism is to lift App::display_value_of into a free\n`display_value_of(layout, model, drill, key)` in ui/app.rs that both\n`App::display_value_of` and a new `CmdContext::display_value()` call, so there is\none derivation with two callers. Cheap and self-contained.\n\nTHE EAGER COST IS NOT display_value, AND THE BEAD NAMES THE WRONG FIELD.\n`display_value_at_cursor` is one `get_cell`. `visible_cols` is the expensive one:\n`cmd_context()` computes it by calling `ui::viewport::compute_col_widths`, which\nwalks the WHOLE GRID TWICE (see improvise-jmjz). Benchmarked this wave at 169ms\nfor 18,000 cells -- and `cmd_context` is built on every keypress. Gotcha 23\nalready warns \"don't build a CmdContext inside apply (it recomputes column\nwidths)\" without saying what that costs. Making `visible_cols` lazy is worth more\nthan making `display_value` lazy by about four orders of magnitude, and it is\ncomputable from the fields the context already holds plus the terminal width.\nGROOMED 2026-09-19 (restored; a --notes above replaced it): still true; CmdContext is cmd/core.rs:18-59 (cited 15-56) and still copies 25 fields, including the eagerly-computed display_value.","status":"open","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:30Z","started_at":"2026-09-20T18:08:15Z","labels":["area:command"],"dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"improvise-2ud","title":"CmdContext copies individual fields instead of borrowing ModelState/ViewState slices","description":"crates/improvise-command/src/command/cmd/core.rs:15-56 + crates/improvise-command/src/ui/app.rs cmd_context(): after the ModelState/ViewState split (improvise-x2c/ew0), CmdContext still copies ~20 individual fields (dirty, search_query, panel flags/cursors, nav stacks) out of the slices. Follow-up: have CmdContext borrow \u0026ModelState/\u0026ViewState (or narrowed sub-views) so new fields don't require threading through cmd_context() and there is one source of truth. Also: display_value is computed eagerly on every context build (app.rs ~388-406) even when the command never reads it — consider lazy/on-demand. Complements improvise-drg (narrow Effect::apply signatures). Found in systematic review 2026-06-09.","acceptance_criteria":"CmdContext borrows \u0026ModelState/\u0026ViewState (or narrowed sub-views) instead of copying ~20 individual fields, so a new field does not have to be threaded through cmd_context(). display_value stops being computed eagerly on every context build when no command reads it. Note improvise-rw3a is waiting on this: it needs file_path on the context and should not add a 21st copied field ahead of the refactor.","notes":"SIZED, NOT DONE (wave 2, 2026-09-20). Attempted and reverted rather than\nhalf-landed. The bead reads as a cheap cleanup and is not one; here are the\ncounts, so the next attempt is planned rather than discovered.\n\nMEASURED SCOPE\n- 104 `ctx.\u003cfield\u003e` reads across 14 files (panel.rs 21, commit.rs 16,\n registry.rs 15, text_buffer.rs 13, search.rs 8, grid.rs 7, mode.rs 6,\n keymap.rs 4, navigation.rs 4, tile.rs 3, cell.rs 2, core.rs 2, wizard.rs 1,\n and 2 in improvise-wasm-client/src/render.rs).\n- 46 sites that MUTATE a ctx field in place to set a scenario up\n (`ctx.buffers = \u0026bufs;`, `ctx.mode = \u0026item_add_mode;`, ...). These cannot be\n rewritten mechanically: once CmdContext borrows the slice, the ViewState has\n to be built and mutated BEFORE the context exists, so each is a per-test\n restructure.\n- 99 `make_ctx` call sites, whose helper has to change shape to own or take a\n \u0026ViewState. That reaches essentially every command-layer test.\n\nDESIGN THAT WAS REACHED (worth keeping, it is most of the work)\n CmdContext { model, workbook, view, layout, registry,\n view_state: \u0026ViewState, model_state: \u0026ModelState,\n drill: Option\u003c\u0026DrillState\u003e, wizard, visible_rows, visible_cols,\n key_code }\nEach removed field is read at its one home (`ctx.view_state.mode`,\n`ctx.model_state.dirty`) rather than through an accessor, because an accessor\nbeside the field is a second answer to the same question. `has_drill_state: bool`\nbecomes `drill: Option\u003c\u0026DrillState\u003e` -- strictly more information, and it is what\nmakes the laziness possible.\n\nDISPLAY_VALUE: the mechanism is to lift App::display_value_of into a free\n`display_value_of(layout, model, drill, key)` in ui/app.rs that both\n`App::display_value_of` and a new `CmdContext::display_value()` call, so there is\none derivation with two callers. Cheap and self-contained.\n\nTHE EAGER COST IS NOT display_value, AND THE BEAD NAMES THE WRONG FIELD.\n`display_value_at_cursor` is one `get_cell`. `visible_cols` is the expensive one:\n`cmd_context()` computes it by calling `ui::viewport::compute_col_widths`, which\nwalks the WHOLE GRID TWICE (see improvise-jmjz). Benchmarked this wave at 169ms\nfor 18,000 cells -- and `cmd_context` is built on every keypress. Gotcha 23\nalready warns \"don't build a CmdContext inside apply (it recomputes column\nwidths)\" without saying what that costs. Making `visible_cols` lazy is worth more\nthan making `display_value` lazy by about four orders of magnitude, and it is\ncomputable from the fields the context already holds plus the terminal width.\nGROOMED 2026-09-19 (restored; a --notes above replaced it): still true; CmdContext is cmd/core.rs:18-59 (cited 15-56) and still copies 25 fields, including the eagerly-computed display_value.","status":"open","priority":3,"issue_type":"task","assignee":"wave2-n-cleanups","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:05Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:30Z","started_at":"2026-09-20T18:08:15Z","labels":["area:command"],"dependencies":[{"issue_id":"improvise-2ud","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-gai","title":"AGENTS.md session-completion workflow has bare 'git merge' with no target","description":"AGENTS.md Session Completion step 4 reads 'git merge' with no ref (CLAUDE.md's copy correctly says 'git merge origin/main'). An agent following AGENTS.md literally runs bare git merge, which errors or merges the wrong thing. The two files also duplicate the whole beads/session block with this one divergence — consider a single source of truth. Found in systematic review 2026-06-09.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:03Z","created_by":"Edward Langley","updated_at":"2026-06-10T04:46:48Z","closed_at":"2026-06-10T04:46:48Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-wg1","title":"Add GNU Global tags and graphviz artifacts to .gitignore","description":"git status shows untracked GPATH, GRTAGS, GTAGS (GNU Global, generated by the gtags devShell tooling added in 62d18ff) and graph.dot/graph.html/graph.svg at the repo root. None are covered by .gitignore, risking accidental commits. Add: GPATH, GRTAGS, GTAGS, /graph.dot, /graph.html, /graph.svg (or /graph.*). Found in systematic review 2026-06-09.","notes":"2026-06-09: the git-smart-commit hook added these artifacts to .git/info/exclude (machine-local), so they no longer show in status here — but the shared .gitignore still lacks them; issue remains valid for other clones.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:59:00Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:32:19Z","closed_at":"2026-09-19T20:32:19Z","close_reason":"Added exact-name ignores to the root .gitignore: GTAGS/GRTAGS/GPATH/GSYMS for GNU Global, and root-anchored /graph.dot, /graph.html, /graph.svg for the graphviz artifacts. Deliberately not globs like *.svg, which would swallow checked-in assets. Verified no tracked file becomes ignored: 'git ls-files | git check-ignore --stdin' printed nothing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-v9w","title":"Remove unused pest/pest_derive from root crate [dependencies]","description":"Root Cargo.toml:38-39 lists pest and pest_derive under [dependencies], but nothing under src/ uses them (grammar parsing lives in improvise-io; examples use pest_meta from dev-dependencies). They are also duplicated under [dev-dependencies]. Remove from [dependencies]; verify with cargo build --workspace \u0026\u0026 cargo test --workspace. Found in systematic review 2026-06-09.","notes":"Also unused: dirs = \"6\" (root Cargo.toml:36). Found while doing improvise-6mq, whose acceptance criterion 3 was 'dirs crate usage is isolated behind a function that can be stubbed for wasm' — there is no dirs usage anywhere under src/ or crates/*/src/ to isolate, so that criterion is vacuous. Same class as the pest/pest_derive removal this ticket already covers; fold it in.\nGROOMED 2026-09-19: still true; line numbers shifted — pest/pest_derive are Cargo.toml:40-41 under [dependencies] and 44-46 under [dev-dependencies] (with pest_meta).","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-06-10T03:58:53Z","created_by":"Edward Langley","updated_at":"2026-09-19T20:32:18Z","closed_at":"2026-09-19T20:32:18Z","close_reason":"Removed pest, pest_derive and dirs from the root crate [dependencies]. Premise verified by build, not by reading: examples/gen-grammar.rs uses pest_meta but gets it from [dev-dependencies], so cargo build --examples stays green. dirs had no usage anywhere under src/ or crates/*/src/ and was folded in per this ticket's own notes. cargo build --workspace, cargo test --workspace (801 passed), cargo build --examples and the four-crate wasm32 check all clean. Cargo.lock drops only dirs + transitives; pest* remain for improvise-formula/improvise-io.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -742,44 +877,48 @@ {"_type":"issue","id":"improvise-t8s","title":"View::axis_of panics on missing category","description":"crates/improvise-core/src/view/types.rs:118 uses expect('axis_of called for category not registered with this view'). The invariant is that Workbook::add_category registers every category with every view, but the panic surface is fragile for new callers. Fix: either audit all callers and document the invariant clearly, or change signature to fn axis_of(\u0026self, cat: \u0026str) -\u003e Option\u003cAxis\u003e and let callers handle None.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:48Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:47Z","closed_at":"2026-06-10T04:46:47Z","close_reason":"Fixed (test-first where applicable): records_display returns None for OOB columns (f04fe51); try_axis_of added + axis_of invariant documented + cycle_axis no longer panics on unregistered categories (f04fe51); scrolled header suppression fixed via pure show_sublabel helper for both row and column headers (df9a02b); CyclePanelFocus cycles from current panel with wrap (9e02245); AGENTS.md merge target corrected (9634c95).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-oaq","title":"Redundant file re-read in ImportJsonHeadless effect","description":"src/ui/effect.rs:846 chains 'serde_json::from_str(\u0026std::fs::read_to_string(\u0026self.path).unwrap_or_default()).unwrap_or(serde_json::Value::Array(records.clone()))'. This re-reads and re-parses the JSON file after records were already parsed earlier (lines 799-812). Dead code path from earlier refactor. Fix: remove the redundant read, use the already-parsed records array.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:45Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:39Z","closed_at":"2026-06-10T04:46:39Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-mzv","title":"Import wizard does not pre-check 12-category limit","description":"crates/improvise-io/src/import/wizard.rs:84-150 build_model() fails when \u003e12 categories are proposed, but the ImportWizard UI does not pre-check before allowing the user to confirm. A 20-column CSV imported as 20 categories fails late with 'Category limit exceeded' after the user commits. Fix: validate category count in the UI step and require the user to mark some as Measure/Skip before proceeding. Also add a test for the \u003e12 case.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-16T18:18:44Z","created_by":"Ed L","updated_at":"2026-06-10T04:46:38Z","closed_at":"2026-06-10T04:46:38Z","close_reason":"Fixed in f0b9227/77a5124 (test-first): short CSV rows padded with explicit nulls + per-column warnings threaded to status line (stderr on CLI path); wizard pre-checks 12-category limit at advance with actionable message rendered in UI; ImportJsonHeadless parses the file exactly once via extracted json_import_records helper.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (crates/improvise-command/src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).\nGROOMED 2026-09-19: still true; Keymap::dispatch is keymap.rs:255-272 now (cited 261-267) and the Sequence arm still feeds every step the same pre-dispatch ctx.\nDEFERRED by PR #27 (WU-G) with the design note and its guard, rather than built. The user accepted the deferral.\n\nKeymap::dispatch calls each Sequence step's execute(ctx) with the SAME PRE-DISPATCH ctx, so a later command cannot observe an earlier one's effects. True command-level transactionality means, between steps: apply effects so far, rebuild layout, build a fresh CmdContext, dispatch the next.\n\nTHREE FINDINGS, ALL CHECKABLE, AND THE FIRST IS DISQUALIFYING:\n\n1. IT WOULD SILENTLY REOPEN improvise-8tk, A P1 DATA LOSS. apply_effects resets abort_effects at entry and when it breaks, because the flag is per batch. Applying between steps dispatches step 2 into a FRESH batch with the flag cleared -- so the Quit after a failed Save lands and the model is discarded. Gotcha 21's claim that the abort carries across Sequence steps is true BECAUSE OF the flattening 0hb removes, and it was UNTESTED until this PR.\n\n2. ALMOST NOTHING BENEFITS. Of 21 shipped Sequence bindings, 20 are TextEntry's [\u003ccommit\u003e, clear-buffer] or [clear-buffer, enter-mode], whose second step reads nothing from ctx. The one real composition, records `o`, already works because EnterEditAtCursor's effect self-heals with rebuild_layout. Every Esc and commit key would pay a layout rebuild plus a CmdContext build, which recomputes column widths.\n\n3. THE MOTIVATING REWRITE IS NOT DISPATCHABLE -- see improvise-git.\n\nLANDED AS THE GUARD: a_sequence_whose_first_step_aborts_does_not_run_the_second, the first test to drive a Sequence whose first step aborts. Fault injected by making the abort per-effect, which is what 0hb would do; it failed.\n\nTO UN-DEFER: the blocker is (1). Any design that applies between steps must first answer what abort_effects means across batch boundaries -- and note PR #26 has since made that harder rather than easier, because clause 4 (improvise-yqpd) wants a ROLLBACK at apply_effects, and an abort is not a rollback. Decide those two together or not at all.","status":"deferred","priority":3,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-09-20T07:54:40Z","started_at":"2026-09-20T05:32:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.\nDEFERRED 2026-09-19 by user ruling. Stretch item, and its natural home is the e0u restructuring (also deferred): a command log is most useful once Commands are genuinely the universal unit of intent through one reducer, which is what e0u finishes.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.\nDEFERRED 2026-09-19: blocked by improvise-e0u, which was deferred by user ruling this session. Hybrid mode needs the in-process client/server split before a second subscriber can attach.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0hb","title":"Make Sequence bindings transactional at command level","description":"Currently Keymap::dispatch (crates/improvise-command/src/command/keymap.rs:261-267) calls each Sequence step's execute(ctx) with the same pre-dispatch ctx. Effects accumulate in order and apply atomically as a batch via App::apply_effects, but later commands in a Sequence cannot observe earlier commands' effects through ctx — only effects-after-effects can see prior mutations.\n\nFor example, the records 'o' sequence [add-record-row, enter-edit-at-cursor records-editing] works only because the EnterEditAtCursor effect itself calls app.rebuild_layout() before reading display_value. Effects can self-heal mid-batch, but commands cannot.\n\nTrue command-level transactionality would require, between Sequence steps: apply effects-so-far, rebuild layout, build a fresh ctx, then dispatch the next command. This would change the semantics of every existing Sequence and is worth its own design pass.\n\nAcceptance: design doc + implementation; existing Sequences keep working; new tests prove a later step sees prior steps' model mutations.","notes":"Surfaced while parameterizing EnterEditAtCursor / CommitAndAdvance to remove is_records() runtime checks (improvise-4ju).\nGROOMED 2026-09-19: still true; Keymap::dispatch is keymap.rs:255-272 now (cited 261-267) and the Sequence arm still feeds every step the same pre-dispatch ctx.\nDEFERRED by PR #27 (WU-G) with the design note and its guard, rather than built. The user accepted the deferral.\n\nKeymap::dispatch calls each Sequence step's execute(ctx) with the SAME PRE-DISPATCH ctx, so a later command cannot observe an earlier one's effects. True command-level transactionality means, between steps: apply effects so far, rebuild layout, build a fresh CmdContext, dispatch the next.\n\nTHREE FINDINGS, ALL CHECKABLE, AND THE FIRST IS DISQUALIFYING:\n\n1. IT WOULD SILENTLY REOPEN improvise-8tk, A P1 DATA LOSS. apply_effects resets abort_effects at entry and when it breaks, because the flag is per batch. Applying between steps dispatches step 2 into a FRESH batch with the flag cleared -- so the Quit after a failed Save lands and the model is discarded. Gotcha 21's claim that the abort carries across Sequence steps is true BECAUSE OF the flattening 0hb removes, and it was UNTESTED until this PR.\n\n2. ALMOST NOTHING BENEFITS. Of 21 shipped Sequence bindings, 20 are TextEntry's [\u003ccommit\u003e, clear-buffer] or [clear-buffer, enter-mode], whose second step reads nothing from ctx. The one real composition, records `o`, already works because EnterEditAtCursor's effect self-heals with rebuild_layout. Every Esc and commit key would pay a layout rebuild plus a CmdContext build, which recomputes column widths.\n\n3. THE MOTIVATING REWRITE IS NOT DISPATCHABLE -- see improvise-git.\n\nLANDED AS THE GUARD: a_sequence_whose_first_step_aborts_does_not_run_the_second, the first test to drive a Sequence whose first step aborts. Fault injected by making the abort per-effect, which is what 0hb would do; it failed.\n\nTO UN-DEFER: the blocker is (1). Any design that applies between steps must first answer what abort_effects means across batch boundaries -- and note PR #26 has since made that harder rather than easier, because clause 4 (improvise-yqpd) wants a ROLLBACK at apply_effects, and an abort is not a rollback. Decide those two together or not at all.","status":"deferred","priority":3,"issue_type":"feature","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-04-16T05:45:49Z","created_by":"fido","updated_at":"2026-09-20T07:54:40Z","started_at":"2026-09-20T05:32:32Z","dependencies":[{"issue_id":"improvise-0hb","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:10Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-m91","title":"(Stretch) Command log + native undo/replay","description":"Once Commands are the universal unit of intent, log them per-session and support undo/redo via inverse commands or periodic snapshots. Enabled by the unified reducer; valuable for all three deployment modes but especially natural in the native TUI. Also enables debugging and reproducing bugs: a command log is a deterministic replay.","design":"Two approaches: (1) Inverse commands — each command definition includes how to undo it; undo-stack walks the log in reverse applying inverses. Clean but requires per-command inverse logic. (2) Snapshot-and-replay — periodic snapshots of ModelState + current log index; undo rolls back to nearest snapshot and replays up to target index. Simpler, works for any command, costs memory + replay time. Start with (2) for simplicity. The log is per-session (ClientSession.command_log) and survives only within a session. Persistence to disk as a .improv-journal companion file is a further enhancement. Replay use case: load a file, enable log recording, hit a bug, save the log, bug-report-as-replay.","acceptance_criteria":"(1) Every dispatched Command is logged in the ClientSession (or equivalent server-side state). (2) An Undo Command pops the log and restores state via snapshot+replay. (3) Redo re-applies. (4) Native TUI bindings for Ctrl-Z / Ctrl-Shift-Z. (5) Replay test: record a sequence, reset, replay, assert final state equals record-end state.","notes":"Stretch goal — valuable across all deployment modes but not part of the core architectural unification. Depends on issue improvise-gxi (reduce_full) and ideally on a persistence mechanism (improvise-6mq VFS abstraction) if logs should survive restart.\nDEFERRED 2026-09-19 by user ruling. Stretch item, and its natural home is the e0u restructuring (also deferred): a command log is most useful once Commands are genuinely the universal unit of intent through one reducer, which is what e0u finishes.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:53:02Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-m91","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:08Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-m91","depends_on_id":"improvise-gxi","type":"blocks","created_at":"2026-04-14T00:53:44Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-uq7","title":"(Stretch) Hybrid mode: native TUI with remote browser observer","description":"Once the native binary has the in-process client/server split (issue improvise-e0u), allow a second subscriber to attach via a local ws-server thread. Result: a developer running the native TUI can share their session with a teammate in a browser, or have their own browser tab observing while they work in the terminal. Both subscribers receive projection commands from the same in-process server; commands from either side flow through the same reduce_full.","design":"Native binary gains a --serve PORT flag (or similar) that, when set, spawns a ws-server thread alongside the native client. The ws-server is exactly improvise-q08's server logic, attached to the same in-process App as the native client. Two ClientSessions: one for the local ratatui client (zero-transport), one (or more) for remote websocket clients. Each has its own ViewState + render cache + subscription set. The projection layer fans out effect-driven updates to every session. The two subscribers see independent cursor/scroll/mode state (because view state is per-session) but the same model state (because model is shared). This is the deployment mode that fully justifies per-session view state tracking on the server.","acceptance_criteria":"(1) Native binary has an optional --serve flag that spawns a ws-server in the same process. (2) A remote browser can connect and see the native user's current session (shared model, independent view). (3) Edits from either side show up on the other after a round trip. (4) Graceful disconnect/reconnect of remote clients while native keeps running.","notes":"Stretch goal — not required for the core unification. Depends on issue e0u (binary split done) and improvise-q08 (ws-server exists). Orthogonal to the browser and standalone epics. The architectural investment in per-session view state and subscription-based projections is what makes this possible with minimal additional code.\nDEFERRED 2026-09-19: blocked by improvise-e0u, which was deferred by user ruling this session. Hybrid mode needs the in-process client/server split before a second subscriber can attach.","status":"deferred","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:52:57Z","created_by":"spot","updated_at":"2026-09-20T05:31:02Z","dependencies":[{"issue_id":"improvise-uq7","depends_on_id":"improvise-e0u","type":"blocks","created_at":"2026-04-14T00:53:42Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-ltq","type":"parent-child","created_at":"2026-09-25T01:04:49Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-uq7","depends_on_id":"improvise-q08","type":"blocks","created_at":"2026-04-14T00:53:43Z","created_by":"spot","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-9ix","title":"SQLite persistence format (alternative save/restore)","description":"Implement .sqlite as an alternative file format alongside .improv. Tables for categories, items, cells, formulas, views. Works over any VFS backend — native filesystem, OPFS, etc. — because the underlying storage layer is abstracted. Enables structured queries over model state and provides a more scalable container than plaintext for very large models. Orthogonal to the core standalone deployment; filed as a backlog follow-on since the MVP doesn't need it.","design":"Use rusqlite with the 'bundled' feature so sqlite itself compiles into the wasm bundle. Schema: one row per cell keyed by (coord_hash, category_coords...); one row per category with kind + ordering; one row per item with group membership; one row per formula with raw + target. Views stored as JSON or as structured rows. Persistence API mirrors .improv: save_sqlite(storage, path) / load_sqlite(storage, path). Rusqlite on wasm may need custom VFS hookup — investigate sqlite-wasm-rs or similar for browser-friendly builds. Round-trip tests: model→sqlite→model must be identity.","acceptance_criteria":"(1) save_sqlite and load_sqlite functions exist and round-trip cleanly. (2) rusqlite links against the VFS abstraction correctly on both native and wasm. (3) Unit tests for schema, round-trip, and edge cases. (4) File manager UI (if any) offers .sqlite as a save format option.","notes":"Depends on improvise-6mq (VFS abstraction). Independent of the standalone deployment critical path — can be tackled any time after the Storage trait lands. Filed as backlog (P3).","status":"closed","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:41Z","created_by":"spot","updated_at":"2026-09-20T00:08:35Z","closed_at":"2026-09-20T00:08:35Z","close_reason":"DECLINED with measurements, not intuition. No code written; the tree was left untouched.\n\nALL THREE OF THE TICKET CLAIMS WERE TESTED:\n\n(1) \"Works over any VFS backend because the storage layer is abstracted\" -- FALSE. improvise-6mq did not deliver a VFS. Storage is four whole-document operations (read/write bytes, list, delete) with no offsets, seeking, handles or locking; invariant(storage_is_two_ops) states it as doctrine. An sqlite3_vfs needs xRead(offset,len)/xWrite/xTruncate/xLock and only the first two could even be faked -- by rewriting the whole database on every page access.\nBUT the agent PROVED SQLite can still fit before declining: rusqlite with features=[bundled,serialize] round-trips a database through bytes with zero filesystem contact (probe test passed; a one-row DB serializes to 8192 bytes). So the decline rests on VALUE, not on imagined plumbing trouble -- \"declining on a capability that actually exists would have been the wrong decline\".\n\n(2) \"A more scalable container than plaintext for very large models\" -- FALSE, MEASURED on an 18,000-cell model using the ticket own schema, VACUUMed:\n big.improv 1,280,846 bytes\n big.sqlite 1,568,768 +22%\n big.improv.gz 136,264\n big.sqlite.gz 642,897 4.7x LARGER\nFloor case: a 5-table schema holding 3 rows is 24,576 bytes against roughly 150 bytes of .improv -- about 160x. And because writes go through Storage::write(whole bytes), every save still rewrites the entire document, so the one dimension where SQLite normally wins -- incremental page writes -- is unavailable.\n\n(3) \"Enables structured queries over model state\" -- NOT DELIVERED by this design. The artifact is a closed blob; querying means deserializing the whole database into RAM beside the Model that is already fully in RAM. Anything you could ask it you can already ask DataStore::matching_values/matching_cells over interned, indexed data.\n\nALSO: cargo check --target wasm32-unknown-unknown fails in libsqlite3-sys C build. The agent was precise that this is the NIX CC-WRAPPER injecting -fzero-call-used-regs, not proof bundled SQLite can never target wasm -- but it is real evidence the repo wasm CI gate would break the day this landed.\n\nTHE COST THE TICKET DOES NOT PRICE: .improv is grammar-defined and the pest grammar is the single source of truth. A SQLite writer/reader is a SECOND IMPLEMENTATION OF THE WHOLE FORMAT -- groups, formula target categories, hidden items, collapsed groups, number formats, page/filter selections, IndexMap insertion order, the _Measure explicit-vs-formula-target split, virtual-view suppression -- which must track every future grammar change forever. And the ticket own acceptance test (model-\u003esqlite-\u003emodel identity) CANNOT DETECT DRIFT from .improv, because it never compares the two. That missing cross-format test is what made the ticket look cheap.\n\nFormat dispatch and naming were both checked and are NOT the problem: save_to/load_from are the only chokepoints, SQLite 16-byte header is a cleaner discriminator than the existing leading-brace JSON sniff, and .improv.sqlite would follow invariant(gzip_is_a_name_property).\n\nSALVAGE FILED SEPARATELY: the one claim with standing is INTEROP -- opening a model in sqlite3 or DB Browser. That is EXPORT, not persistence.","dependencies":[{"issue_id":"improvise-9ix","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:34Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-avy","title":"Formula tokenizer: support quoted identifiers for ambiguous names","description":"The formula tokenizer currently uses heuristics to handle multi-word identifiers (greedy space consumption with keyword/operator break rules). This is fragile — we just fixed a bug where WHERE inside aggregates was consumed as part of an identifier.\n\nA more robust approach: support SQL/CL-style quoted identifiers. Two candidate syntaxes:\n- SQL style: double-quotes for identifiers, e.g. \"Total Revenue\" = \"Base Revenue\" + Bonus\n- CL style: pipe-delimited, e.g. |Total Revenue| = |Base Revenue| + Bonus\n\nSQL double-quoting is natural since the formula syntax already uses quotes for WHERE filter string literals. Disambiguation: in expression position, a quoted string becomes Token::Ident; in WHERE value position, it becomes Token::Str (parser already distinguishes these contexts).\n\nThis would let us simplify the tokenizer's space-handling heuristics and cleanly support category/item names that collide with keywords (WHERE, SUM, IF, etc.).","status":"closed","priority":3,"issue_type":"feature","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:45:27Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:48:41Z","closed_at":"2026-04-09T06:48:41Z","close_reason":"Implemented pipe-quoted identifiers |...| in the formula tokenizer. Pipes produce Token::Ident, work in expressions, aggregates, and WHERE clauses. split_where and parse_where also updated. 6 new tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0zf","title":"Bug: WHERE inside aggregate parens broken by greedy identifier tokenizer","description":"The tokenizer treats multi-word identifiers greedily — 'Revenue WHERE' becomes a single token. This means SUM(Revenue WHERE Region=East) doesn't parse correctly. The WHERE keyword is consumed as part of the identifier. Top-level WHERE (outside parens) works because split_where handles it before tokenization. Fix: either stop allowing spaces in identifiers inside parens, or detect WHERE as a keyword break point in the tokenizer.","status":"closed","priority":3,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T06:06:15Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:38:08Z","closed_at":"2026-04-09T06:38:08Z","close_reason":"Fixed tokenizer to break multi-word identifiers at keywords (WHERE, SUM, AVG, MIN, MAX, COUNT, IF). Two-pronged: (1) break when current identifier IS a keyword, (2) break when next word IS a keyword. SUM(Revenue WHERE Region=East) now parses correctly.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-w82","title":"Test audit: persistence/mod.rs at 82% coverage","description":"persistence/mod.rs is at 82% - just above the floor. Missing coverage for edge cases: empty models, special characters in names, view state persistence details, and formula preservation corner cases.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:46Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:15:10Z","closed_at":"2026-04-09T06:15:10Z","close_reason":"Added 16 tests covering save/load roundtrip (plain + gzip), autosave_path, export_csv, collapsed groups, page-without-selection, none axis, number format, text values, multiple views, and full feature roundtrip. Coverage from 82% to 97%.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-kq6","title":"Test audit: formula/parser.rs at 76% coverage","description":"formula/parser.rs is at 76% line coverage, just under the 80% target. Missing coverage for MIN, MAX, COUNT aggregate functions, complex nested expressions, and error/edge case paths.","status":"closed","priority":3,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-04-09T05:04:44Z","created_by":"Edward Langley","updated_at":"2026-04-09T06:10:12Z","closed_at":"2026-04-09T06:10:12Z","close_reason":"Added 19 tests covering MIN/MAX/COUNT aggregates, comparison operators, power/unary/mul/div, WHERE with quotes, error paths, multi-word identifiers, aggregate-name-as-ref edge case. Coverage from 76% to 89%. Found bug: WHERE inside aggregate parens broken by greedy tokenizer (improvise-0zf).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-gx5d","type":"blocks","created_at":"2026-09-22T05:26:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":1,"comment_count":0} -{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"improvise-3tj","title":"3.3 Tag v0.1.0 release","description":"Create git tag v0.1.0, push it, verify cargo dist workflow produces release artifacts. Update README prebuilt binaries link to point at actual release.","acceptance_criteria":"Tag v0.1.0 exists and is pushed, the cargo-dist workflow has produced release artifacts for Linux x86_64 and macOS (Intel + Apple Silicon), and README's prebuilt-binaries link points at the actual release rather than a placeholder. NEEDS THE USER: tagging and pushing a release is theirs to authorise, and it is ordered after improvise-l36.","notes":"STATE CHECKED 2026-09-20 at 52507a6 (wave 2, WU-P), and one premise of this\nticket is already false.\n\nThe README's prebuilt-binaries link is NOT a placeholder. `v0.1.0-rc1` and\n`v0.1.0-rc2` both exist as prereleases and `v0.1.0-rc2` carries real cargo-dist\nartifacts: improvise-{aarch64,x86_64}-apple-darwin.tar.xz,\nimprovise-{aarch64,x86_64}-unknown-linux-gnu.tar.xz, each with a .sha256, plus\nimprovise-installer.sh, sha256.sum, dist-manifest.json and source.tar.gz. So\ncargo-dist demonstrably works on this repository, and the four targets in\ndist-workspace.toml are the four that were built.\n\nWhat remains is therefore narrower than the ticket reads: there is no `v0.1.0`\ntag, only the two rc tags, and the rcs are marked prerelease so the releases\npage has no \"Latest\". README was updated in wave 2 to say exactly that rather\nthan to imply a stable release exists.\n\nMECHANISM: `.github/workflows/release.yml` fires on a pushed version tag and\nruns dist itself. Nothing needs to be built by hand; the tag is the trigger.\n\nBEFORE TAGGING: ten manifests say `0.1.0-rc2` (and `crates/gp-tree` says\n`0.1.0`). A `v0.1.0` tag on manifests that say `0.1.0-rc2` makes dist announce\na version nothing in the tree claims, so the bump comes first, in the same\ncommit, and gp-tree's disagreement is resolved with it.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:02Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:39Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-3tj","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:35Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-gx5d","type":"blocks","created_at":"2026-09-22T05:26:55Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-3tj","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T21:09:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-l36","title":"3.2 Publish to crates.io","description":"After cargo publish --dry-run is clean and user confirms, run cargo publish. Verify crate appears at crates.io/crates/improvise and cargo install improvise works.","acceptance_criteria":"improvise is on crates.io and 'cargo install improvise' works from a clean machine. NEEDS THE USER: crates.io credentials, and their confirmation after 'cargo publish --dry-run' is clean. Note the workspace has grown to nine crates since this was filed — decide which are published and in what order, since a dependent cannot publish before its dependency, and improvise-io/protocol/command are all now real crates rather than internal modules.","notes":"DRY RUN ACTUALLY RUN, 2026-09-20 at 52507a6 (wave 2, WU-P). It is NOT clean,\nand the blocker is mechanical rather than a judgement call:\n\n error: failed to verify manifest at Cargo.toml\n all dependencies must have a version requirement specified when publishing.\n dependency `gp-tree` does not specify a version\n\ncargo stops at the FIRST offender, and gp-tree is not the only one. Every\ninter-crate dependency in this workspace is `{ path = \"...\" }` with no\n`version`, which crates.io forbids for normal dependencies:\n\n Cargo.toml (root) gp-tree, improvise-core, improvise-formula,\n improvise-io, improvise-command\n improvise-core improvise-formula\n improvise-formula (none)\n improvise-protocol improvise-core\n improvise-io improvise-core, improvise-formula\n improvise-gp gp-tree, improvise-core, improvise-formula\n improvise-command improvise-core, improvise-protocol,\n improvise-formula, improvise-io, improvise-gp\n\nDev-dependencies are exempt — cargo strips them on publish — so the root's\n`improvise-wasm-client`/`improvise-protocol` dev deps and improvise-wasm-\nclient's dev dep on the unpublishable improvise-ws-server are fine as they are.\n\nWHICH CRATES PUBLISH. `improvise-ws-server` and `improvise-worker-server` both\ncarry `publish = false` already, so the set is eight, and a dependent cannot go\nbefore its dependency. The only order that works:\n\n 1. improvise-formula (no intra-workspace deps)\n 2. improvise-core\n 3. gp-tree (no intra-workspace deps; can go any time before 4)\n 4. improvise-gp\n 5. improvise-protocol\n 6. improvise-io\n 7. improvise-command\n 8. improvise (the binary)\n\ncrates.io needs a minute or two to index each one before the next can resolve\nit, so this is eight sequential `cargo publish` calls, not one.\n\nTWO VERSION FACTS THE USER MUST DECIDE. `gp-tree` is at `0.1.0` while every\nother crate is at `0.1.0-rc2`; and a `-rc2` version requirement in a published\nmanifest pins consumers to a prerelease. If the intent is to publish 0.1.0\n(which improvise-3tj's tag implies), the version bump across ten manifests\nshould happen BEFORE the version requirements are written, or they will be\nwritten twice.\n\nNAME AVAILABILITY CHECKED: crates.io returns \"crate `improvise` does not exist\",\nso the name is free. Not checked: the other seven names.","status":"open","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:58Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:01:38Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-l36","depends_on_id":"improvise-0s6","type":"parent-child","created_at":"2026-09-25T01:07:36Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T21:09:29Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-l36","depends_on_id":"improvise-2fr","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-11a","title":"3.1 Configure cargo dist","description":"Run cargo dist init targeting x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin. Skip Windows and musl. Commit generated .github/workflows/release.yml and Cargo.toml additions. Test with v0.1.0-rc1 tag, verify builds, delete rc tag.","status":"closed","priority":3,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:07:55Z","created_by":"Edward Langley","updated_at":"2026-07-30T23:06:31Z","closed_at":"2026-04-11T07:49:57Z","close_reason":"Closed","dependencies":[{"issue_id":"improvise-11a","depends_on_id":"improvise-abz","type":"blocks","created_at":"2026-04-08T21:09:28Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":3,"comment_count":0} -{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-3tj","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-0s6","depends_on_id":"improvise-l36","type":"blocks","created_at":"2026-04-08T23:37:40Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-07i0","title":"spanned_values' admits filter never re-checks base coordinates: a narrowing bug is a wrong answer, not a slow one","description":"From review of PR #239 at 500d4609 (improvise-jq0m). DataStore::spanned_positions chooses between base-key narrowing and per-item narrowing; admits() applies the Exact length check and span membership but not the base key's coordinates, so correctness rests entirely on which narrowing ran. A per-item path that drops the base is caught only by two DataStore-level tests, nothing above. Suggested: admits also checks base coordinates (cheap), turning any future narrowing bug into a speed cost only.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T02:16:39Z","created_by":"orchestrator","updated_at":"2026-09-24T02:16:39Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-gc9t","title":"check-ci-graph refuses a BOM-prefixed workflow and crashes with a traceback on an undecodable one (both fail closed)","description":"## Summary\nFound in adversarial delta review of PR #228 at head 1bec7c0f (xpzh). Both issues fail closed, not open, so this is a P4 nit.\n\n`read_workflow` opens the workflow with `encoding=\"utf8\"`:\n1. **A BOM at the start of the file (U+FEFF) is refused.** It stays on line 1, so `_TOP_KEY` rejects `name: CI` with \"line 1 is a key or `needs:` value this gate cannot read\". go-yaml and PyYAML read that file normally, with `gate.if == false`. The gate refuses a legal workflow loudly.\n2. **An undecodable file crashes with a traceback.** Invalid UTF-8, or a whole-file UTF-16 workflow (which go-yaml and PyYAML both read), raises an uncaught `UnicodeDecodeError`. The exit code is 1, so CI fails, but there is no named complaint.\n\n## Fix\nUse `encoding=\"utf-8-sig\"`, which strips a leading BOM only; a mid-line BOM is invalid YAML and all three parsers reject it. Catch `UnicodeDecodeError` in `main`, and name the file and the byte offset. Add one file case for each.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:07:38Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T02:07:38Z","labels":["adversarial-review","pr-228"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-vav0","title":"Shipped-document walk is polluted by a trace2 target set in config","description":"The shipped-document walk's `git()` in tests/shipped_documents_load.rs does not null the global or system config. That is deliberate, since `every_shipped_document_loads` reads the real checkout. Trace2 can also be enabled from config: `trace2.eventTarget`, `trace2.normalTarget` and `trace2.perfTarget`, which git reads from system and global config. Pointed at `/dev/stdout`, these write into `ls-files -z`'s output exactly as the `GIT_TRACE*` variables did (improvise-2iws). Removing the environment variables, which is what PR #276 does, does not reach config.\n\n## Steps to Reproduce\nAt PR #276 head ddcdf93a (git 2.55.0), write a config file containing `[trace2] eventTarget = /dev/stdout`. Run the test binary with `GIT_CONFIG_GLOBAL=\u003cfile\u003e` (or `GIT_CONFIG_SYSTEM`) and `every_shipped_document_loads --exact`. It panics with `examples/demo.improv does not load: Cannot open \u003croot\u003e/{\"event\":...`. `a_gzipped_document_is_walked` also fails. `normalTarget` pollutes stdout the same way.\n\n## Fix candidate\nSet `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` to `0` in `untraced_git()` instead of removing them. The env var outranks the config target. Verified: with the config above plus `GIT_TRACE2_EVENT=0`, or with `normalTarget` plus `GIT_TRACE2=0`, `ls-files -z` prints only the path.\n\n## Acceptance Criteria\n- A child-process test sets `trace2.eventTarget` (and `normalTarget`) to `/dev/stdout` in a `GIT_CONFIG_GLOBAL` file and re-runs the walk tests, which pass.\n- The same test goes red when the `=0` overrides are dropped.\n\nSame severity as improvise-2iws: the failure is loud and the setting is self-inflicted. Found in the adversarial review of #276.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:08:19Z","created_by":"agent-review-255","updated_at":"2026-09-24T01:11:22Z","started_at":"2026-09-24T01:11:22Z","lease_expires_at":"2026-09-24T01:16:22Z","heartbeat_at":"2026-09-24T01:11:22Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-2iws","title":"Shipped-document walk parses trace output sent to stdout as paths","description":"tests/shipped_documents_load.rs's `git()` strips repo-local vars (--local-env-vars) and, since improvise-a0i3, five pathspec/attribute settings. The GIT_TRACE family is also process-wide and is not stripped. When any of these points at stdout, git writes trace lines into the `ls-files -z` output, and `shipped_documents` parses them as paths:\n- `GIT_TRACE=/dev/stdout`\n- `GIT_TRACE2_EVENT=/dev/stdout`\n- `GIT_TRACE_PERFORMANCE=/dev/stdout`\n\n## Steps to Reproduce\nAt PR #269 head 4cef40c9 (git 2.55.0), run the test binary with `GIT_TRACE=/dev/stdout every_shipped_document_loads --exact`. It panics with \"examples/demo.improv does not load: Cannot open \u003croot\u003e/17:39:40.198221 git.c:502 trace: built-in: git ls-files ...\". The same three settings also fail all four scratch-repo shape tests.\n\nThe failure is loud, not a silent pass. The setting is also self-inflicted: `=1` (stderr) does not break anything. So this is low priority.\n\n## Acceptance Criteria\n- The walk's git removes every `GIT_TRACE*` variable. A prefix match is needed because the family is open-ended.\n- A child-process test covers at least `GIT_TRACE=/dev/stdout` and `GIT_TRACE2_EVENT=/dev/stdout`, and fails with the removal dropped.\n\nFound in the adversarial review of #269.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:56Z","created_by":"agent-review-255","updated_at":"2026-09-24T00:50:14Z","started_at":"2026-09-24T00:50:14Z","lease_expires_at":"2026-09-24T00:55:14Z","heartbeat_at":"2026-09-24T00:50:14Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-0r01","title":"innermost_group walks and clones the whole axis per z keypress since open_runs became the one reader (Codex P2 on #263)","description":"PR #263 (improvise-6u6s) at 2ea24074 made innermost_group call open_runs, which clones the open-run stack for EVERY data item on the axis and keeps all of them, then indexes one. The previous version stopped at idx with O(depth) state. ChatGPT Codex's P2 on #263 is factually right. The cost is O(items x depth) small allocations per z/gz keypress, and z/gz are the only production callers, via row_group_for and col_group_for. On the same keypress, landing() builds a fresh pruned GridLayout and apply_effects rebuilds the layout, and each of those costs O(items) or more with a String allocation per tuple. So the regression is dominated rather than noticeable, which is why it is P4. Fix without losing the single reader: have open_runs yield lazily, or take a stop index, so innermost_group stops at idx and the tests still collect the whole table.","status":"in_progress","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:27Z","created_by":"agent-review-263","updated_at":"2026-09-24T00:36:00Z","started_at":"2026-09-24T00:36:00Z","lease_expires_at":"2026-09-24T00:41:00Z","heartbeat_at":"2026-09-24T00:36:00Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-ybum","title":"browser_round_trip's tile_to and column walk are unbounded loops: a stuck tile/column cursor hangs the binary instead of failing","description":"Found in the adversarial review of PR #256 at head eb146bcc. This predates #256.\n\nIn crates/improvise-wasm-client/tests/browser_round_trip.rs, the helper `tile_to` (line ~434) and the column walk in `the_tile_bar_route_into_records_mode_does_not_strand_the_user` (line ~475) are unbounded `while` loops that press l or h until an index is reached. A resolver fault that stops the cursor short of the target never ends the loop. The test binary hangs; it does not fail.\n\nMeasured: rival `left_clamp_one` changes `tile_cursor`'s lower clamp from `clamp(0, ..)` to `clamp(1, ..)`. With it, that test ran past 17 minutes and had to be killed. The fault is still detected, by two resolve.rs unit tests. But in CI a hang costs the job timeout and names no test, and locally it looks like a slow build.\n\nFix: bound each loop, for example by the tile count or the column count plus one. Assert progress on every press, so that a cursor which does not move fails with the index it is stuck on.\n","status":"in_progress","priority":4,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:08Z","created_by":"agent-review-256","updated_at":"2026-09-24T00:21:21Z","started_at":"2026-09-24T00:21:21Z","lease_expires_at":"2026-09-24T00:26:21Z","heartbeat_at":"2026-09-24T00:21:21Z","labels":["adversarial-review","pr-256"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-aopa","title":"resolve.rs tests fold client-composed commands through fold (the host door), latent like xoxh's renaming test","description":"Found in the adversarial review of PR #259 (improvise-xoxh) at c349d14d. xoxh moved one test's client-composed commands off `ClientState::fold`, the door for what the HOST sends, and onto `fold_outgoing`. Eight test sites in crates/improvise-protocol/src/resolve.rs still fold what `resolve` composed through `fold`: lines 2296 (the Heard classifier), 2356 and 2523 (prefix arming and sequences), 3452 (press_folded), 3612, 3730, 3828 (arm) and 3874.\n\nToday this is latent. None of those tests read `in_flight` or `settled`, and `apply`, which moves `view`, is the same code behind both doors. But a composed `Command::Model` folded through `fold` never enters `in_flight` as awaited. Any future assertion on rollback or settle state in these helpers would therefore describe a client that cannot exist. That is the vacuity xoxh found in the renaming test.\n\nFix candidate: fold resolved commands with `fold_outgoing(\u0026commands)`, one frame per key, which is what web/app.js and lib.rs do. Rival: put `fold` back and assert that some awaited entry or settle-state check goes red. Otherwise the change is untestable, and that is an argument for P4.\n","status":"in_progress","priority":4,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:27Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T00:20:20Z","started_at":"2026-09-24T00:20:20Z","lease_expires_at":"2026-09-24T00:25:20Z","heartbeat_at":"2026-09-24T00:20:20Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1x9h","title":"Browser: a status written while a minibuffer is open hides after TOAST_MS (6s) with the prompt still open — su4u's bug on the toast timer (latent behind cl7y)","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7. The author noted this in the PR body and in `context/code/tui.md` and did not file it.\n\nIn the browser, `status_msg` is shown only as a toast. `web/shell.js::createToasts` hides it after `TOAST_MS` (6000 ms) whatever the mode. The minibuffer element (`web/app.js`, `mb.prompt + mb.value`) never shows the message. So a message that belongs beside an open minibuffer, such as a refusal the user is still fixing, disappears on a timer while the prompt is still open. This is su4u's bug with a different expiry mechanism.\n\nIt is LATENT for su4u's headline case, and that is the reason to file it rather than fix it now. A formula refusal never reaches a browser at all. The client folds `CommitBuffer`'s optimistic \"Formula added\" and `ChangeMode(FormulaPanel)`. The host's \"Formula error\" is a ViewOnly write to the host's own slice. `crates/improvise-wasm-client/tests/lockstep.rs::a_formula_the_client_cannot_parse_diverges_on_the_status_line` still asserts that divergence at 2b1c5bf7, although improvise-cl7y is closed. So this ticket becomes live when a host-addressed status reaches the composer, as `context/code/clients.md` recommends for cl7y, or for any client-composed refusal that keeps a minibuffer open.\n\nAcceptance: a message written while a text-entry mode stays open stays visible until the user leaves that mode or a newer message replaces it. The rule should be the one #257 gives the TUI (see improvise-sx7h for where that rule's mode-equality proxy is wrong). The test is a `web/tests` case on `createToasts` or its caller, plus a Playwright check if the DOM wiring changes.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:32Z","created_by":"agent-review-257","updated_at":"2026-09-24T00:40:40Z","started_at":"2026-09-24T00:40:40Z","lease_expires_at":"2026-09-24T00:45:40Z","heartbeat_at":"2026-09-24T00:40:40Z","labels":["adversarial-review","area:shared-frontend","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mgis","title":"OpenedEditor::awaiting is serde(default), against 1wl0's doctrine, and unpinned: deleting the attribute leaves the workspace green (#227)","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nOpenedEditor::awaiting carries #[serde(default)]. OpenedEditor lives in ViewState, so it travels in the Snapshot. `awaiting: false` is not an absence. It is the claim \"the seed is settled; judge a commit against `seed`\".\n\nA peer that lost the field would deliver an editor that was really awaiting as a settled one with seed \"\". The client would never settle it: the editor stays empty, and an Enter is \"untouched\" and writes nothing. It fails silently. That is the exact shape protocol.md §3a / improvise-1wl0 rejected for AxisFrame::first: \"a missing field is now a loud parse failure at the transport, where a wire-format skew belongs. No skew ships today, both ends being one workspace at one commit.\"\n\nIt is also unpinned. Deleting the attribute leaves the whole workspace green (1950 passed; only my unrelated probes failed). So it is neither the mechanism of any test nor argued in protocol.md.\n\nFix: drop the attribute. Alternatively, keep it with a written argument for why this field differs from 1wl0, plus a test that loads an old-shape OpenedEditor as awaiting=false (per doctrine(populate_before_roundtrip)).\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:45Z","created_by":"agent-review-227c","updated_at":"2026-09-23T18:29:20Z","started_at":"2026-09-23T18:29:20Z","lease_expires_at":"2026-09-23T18:34:20Z","heartbeat_at":"2026-09-23T18:29:20Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4cb5","title":"#227: an awaited seed settles after Esc, writing the landed cell's text into the edit buffer in Normal mode","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nClientState::settle_awaited_seed checks that the editor is awaiting and untouched. It does not check that the mode is still an editing mode. Esc's spine is [clear-buffer edit, enter-mode normal], and entered(Normal) leaves opened_editor in place. So an Esc pressed before the answer arrives leaves an awaiting editor behind. When the answer lands, the client writes the landed cell's seed into the \"edit\" buffer and sets opened_editor = on(seed), all while in Normal mode.\n\nProbe: a real Host and ClientState. Keys i 9 Enter, then Esc before the Enter is answered, then both answers are delivered. The client ends at mode=Normal, edit=\"2\", opened=on(\"2\"). The TUI has edit=\"\" after Esc. This is a lockstep buffer divergence. Its visible effect is small, since `i` re-seeds explicitly. But the buffer is now present, so settle_editor's absent-buffer (ktlk) path will not seed a later editor that arrives without a buffer.\n\nFix: also require self.view.mode.is_editing() in `waiting`. Clearing opened_editor on leaving an editing mode would work too.\n","status":"in_progress","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:43Z","created_by":"agent-review-227c","updated_at":"2026-09-23T18:29:19Z","started_at":"2026-09-23T18:29:19Z","lease_expires_at":"2026-09-23T18:34:19Z","heartbeat_at":"2026-09-23T18:29:19Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h3cv","title":"command.md says tile select's fallback is always _Measure, but in _Drill _Measure is on Filter and the Label/_Index ranks are live and untested","description":"Found reviewing PR #242 (improvise-bqm0/q69m/52od) at head de1524c0.\n\n`context/code/command.md` (the ranking paragraph #242 adds) says \"In practice the\nfallback is always `_Measure`, which every document has.\" That is false in the `_Drill`\nview. `drill_steps` puts every coordinate of the drilled key on `Axis::Filter`,\n`_Measure` included when the cell is a raw measure, and `tileable_cats` drops Filter.\nSo in a drill `_Measure` is not a tile at all.\n\nMeasured with a throwaway probe: Region on Row, `_Measure` on Column, a Label `Note`\non None, one cell, then `\u003e`. The `_Drill` tiles are `[_Index Row VirtualIndex,\n_Dim Column VirtualDim, Note None Label]`, and `opening_tile` returns 2. `T Enter`\nthen puts `Note` on Row next to `_Index`. With no Label and every regular category\ndrilled, only `_Index`/`_Dim` remain, so tile select opens on `_Index`. That is the\noriginal oplp shape, and there is nothing better to open on.\n\nSo the Label rank (and the `_Index` fallback) are live exactly in a drill. No lockstep\nor unit test drives that case: the unit test covers the Label rank with a hand-built\ntile list, and the two lockstep tests use a records view and a view with no regular\ncategory. Fix the sentence, and decide whether a drill case belongs in lockstep.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:45Z","created_by":"agent-review-233-242","updated_at":"2026-09-23T16:14:45Z","labels":["adversarial-review","docs","pr-242"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-xbgm","title":"The client bundle's nontrapping-float-to-int flag is blamed on typed(); bisection says CellValue::edit_text() (#227)","description":"#227 (head a093d126) adds `--enable-nontrapping-float-to-int` to improvise-wasm-client's wasm-opt flags. Its Cargo.toml comment (\"needed here once the client parses typed text\") and the a093d126 commit message both blame `typed()`. Bisected by the adversarial review, building the client bundle with the flag removed:\n- gh/main 5932ad74: builds.\n- #227 head: wasm-opt rejects `i64.trunc_sat_f64_s`.\n- #227 head with `landed_seed` changed to `typed(text).map(|_| text.to_string())` (typed() kept, edit_text() dropped): builds.\n\nSo the instruction comes from `CellValue::edit_text()`, which formats an f64. It does not come from `typed()`, which is already in the bundle through `typed_cell_command`. The flag itself is right: the flag does not change codegen, rustc already emits the instruction, and the worker bundle has needed the flag since gotcha 45. Only the stated reason is wrong. Correct the comment so the next reader does not go looking in the parser.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:53Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:53Z","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-9n2z","title":"cli.md §10 says every key-path decline lands in dispatch's arg-error arm, including i; i never reaches it","description":"Found reviewing PR #238 at c9bbe32e. context/code/cli.md (the 'A bound key whose command will not build is a refusal' paragraph) says dispatch's Err arm 'is where every key-path decline lands, including p, x and i onto an aggregate, a fold, a coordinate column or inside a drill'. Measured through App::dispatch_key: p/x/yy/\u003e/Enter/Tab in Editing on an aggregate or fold do land there (status carries the '\u003ccmd\u003e: ' prefix run() adds). i does not: edit-or-drill is register_pure, its constructor only parses the mode, and its fold decline comes from EditOrDrill::execute (status 'Collapsed group — press z to expand it, then edit an item', no prefix). i on an aggregate drills rather than declines. And 'every key-path decline' is false in general: the three panel.rs declines this same PR converts are execute-side. Fix: name the constructors that can err on context (value_cell_key for paste/clear-cell, cell_key for yank, unfolded_cell_key for drill-into-cell, committable_cell_key for commit-cell-edit/commit-and-advance-right) and drop i.","notes":"Blocked on #238: the sentence exists only on #238's branch. Replacement text proposed in PR #249's body (ab44516). Apply on #238's branch at its next re-merge, or as follow-up after #238 merges.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:58:05Z","created_by":"agent-review-238","updated_at":"2026-09-23T18:15:52Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-bj61","title":"lasd's debug_assert reads the batch-wide refusal flag: a Model effect that aborts without refusing after an earlier refusal in the same batch passes it","description":"Found in adversarial delta review of PR #229 (head 66b6a973). App::apply_effects asserts matches!(kind, ViewOnly) || self.batch_refused when abort_effects is set. batch_refused is set by ANY earlier refusal in the batch, so the check asks 'has this batch refused' where it means 'did this effect refuse'. Demonstrated: with on_view's missing-view branch changed to write the status and abort without refusing (the lasd rival), the batch [refuse(\"earlier\"), SetAxis{view: Named(\"NoSuchView\")}] aborts (last_batch_aborted() == true) and the assert does not fire. The same rival alone fails two tests through the assert. The residual is stated in context/code/command.md (the lasd paragraph) but has no ticket. Cheap fix shape: snapshot a refusal counter (or the flag) before each effect and assert the effect itself refused. Low priority: no current Model effect aborts without refusing; this only narrows what a future fault can slip past. Related: improvise-lasd, improvise-we1a.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:46:02Z","created_by":"agent-review-222b-229b","updated_at":"2026-09-23T15:46:02Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0s6","title":"Phase 3: Distribution","description":"Configure cargo dist, publish to crates.io, tag v0.1.0 release. Produces prebuilt binaries for Linux x86_64 and macOS (Intel + Apple Silicon).","acceptance_criteria":"Closes when improvise-l36 and improvise-3tj close. Both need the user; nothing here is agent work. improvise-11a (cargo dist) is already closed.","notes":"CHECKED, wave 2 WU-P: STAYS OPEN, correctly — both children need the user and\nneither was touched. Nothing was published and nothing was tagged.\n\nWhat this pass DID do is make them ready, and one of them is not as ready as\nit looked. `cargo publish --dry-run` was actually run and FAILS: every\ninter-crate dependency in the workspace is `{ path = ... }` with no `version`,\nwhich crates.io forbids. cargo reports only the first offender (gp-tree), so\nthe dry run understates it — the full list, the publish order for the eight\npublishable crates, and the gp-tree/rc2 version disagreement are on\nimprovise-l36.\n\nimprovise-3tj's premise is already partly false and its note now says so: the\nREADME prebuilt-binaries link is not a placeholder, v0.1.0-rc2 carries real\ncargo-dist artifacts for all four targets, and what is actually missing is the\nv0.1.0 tag and the manifest bump that must precede it.","status":"open","priority":3,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:51Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:05:07Z","labels":["area:release","needs:user"],"dependencies":[{"issue_id":"improvise-0s6","depends_on_id":"improvise-11a","type":"blocks","created_at":"2026-04-08T23:37:39Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1xfb.5.2","title":"Bookkeeping nits from the PR #311 review","description":"Non-blocking findings from the #311 review (issuecomment-5826314337), for the next bookkeeping pass: (1) kb decision scroll_measured_where_drawn cites a tui.md heading imprecisely -- real heading is 'The grid's width is one answer, and a scroll is measured where it is drawn'; (2) repo-map calls the pre-#303 tile-bar code first_tile, a name #303 introduced; (3) category_cap_has_one_home says the literal 24 appears only in add_category_max_limit -- 'the only test' is exact; (4) two user quotes in kb clauses lack quotation marks and drop the final period; (5) three rewritten inventory rows keep stale line counts: import/wizard.rs 1967-\u003e2730, import_wizard_ui.rs 829-\u003e1316, draw.rs 1122-\u003e1189.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","updated_at":"2026-09-25T03:44:37Z","labels":["docs","kb","tooling"],"dependencies":[{"issue_id":"improvise-1xfb.5.2","depends_on_id":"improvise-1xfb.5","type":"parent-child","created_at":"2026-09-25T03:44:37Z","created_by":"Edward Langley","metadata":"{}"}],"comments":[{"id":"a6c01ac1-0730-5e63-8bb6-54a42a2b1046","issue_id":"improvise-1xfb.5.2","author":"Edward Langley","text":"Also for the next bookkeeping pass (PR #315 review, issuecomment-5827266982): (6) repo-map gotcha 45 is stale -- apply_effects raises dirty outright only for WholeWorkbook; for CellData/LayoutGeometry only when observed content/config changed (app.rs:1025-1052); the 'omitting it is unrepresentable' claim needs rewording. (7) repo-map Wasm section cites improvise-fuj (closed) for 'the two effects that still bypass Storage'; the live bypasses are autosave and the headless writes, improvise-31oe.11, and neither is an effect. (8) PR #314's proposed repo-map lines (folder import row, gotcha 27 Filter now persisted in per-file views, gotcha 7 sparse records) and kb facts. (9) PR #317's proposed gotcha 44 rewording and new gotcha 51 once it merges.","created_at":"2026-09-25T05:24:21Z"},{"id":"735d27bf-0c67-587e-8965-12b1a4495c1a","issue_id":"improvise-1xfb.5.2","author":"Edward Langley","text":"Also for the next bookkeeping pass: ADR 004 step 1-2 (PR #319) made stale repo-map entries -- the 'Change an axis label, color or cycle' row, gotcha 26 (axis colour now Face::Axis in improvise-protocol/src/face.rs via draw.rs::face_style), the view/axis.rs inventory line, and face.rs needs an inventory line; kb facts location(axis_presentation), location(axis_color_resolution), invariant(semantic_palette_at_the_core_boundary) cite removed names (improvise-nn4s.11).","created_at":"2026-09-25T17:10:22Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} +{"_type":"issue","id":"improvise-loqe","title":"improvise import --on-duplicate is silently ignored without --no-wizard","description":"Found reviewing PR #304 (improvise-cgqt) at 92233cff. --on-duplicate is documented as applying with --no-wizard, but clap accepts it on a wizard import too, where run_wizard_import ignores the whole ImportConfig (a pre-existing TODO). A user passing --on-duplicate sum without --no-wizard is asked anyway, with no warning that the flag did nothing. Either reject the flag without --no-wizard (clap requires) or pre-seed the wizard's policy.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-25T01:55:40Z","created_by":"Edward Langley","updated_at":"2026-09-25T01:55:40Z","labels":["adversarial-review","cli","import"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-hkhw","title":"ClientState::bootstrap keeps status_seen/status_owner, so a snapshot with the same status text and an equal EditorSession inherits the old document's status ownership","description":"Codex P2 on #298, originally on #279 (https://github.com/fiddlerwoaroof/improvise/pull/279#discussion_r4090596331). bootstrap (crates/improvise-protocol/src/client.rs) resets every cache and in_flight, but it leaves status_seen and status_owner (improvise-1x9h) alone before calling settle_status. When the snapshot's status_msg equals status_seen and EditorSession::of(view) equals the old owner (same mode, same selected cell, same seed), the ownership survives the rebase. status_stands then keeps a toast standing that should expire. The effect is cosmetic: only a toast's lifetime is wrong. Fix: clear both fields in bootstrap before settling. Regression: equal text and an equal EditorSession across a bootstrap; the default state satisfies the other cases.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T19:05:35Z","created_by":"agent-review-298","updated_at":"2026-09-24T19:05:35Z","labels":["adversarial-review","browser","pr-279","pr-298"],"dependencies":[{"issue_id":"improvise-hkhw","depends_on_id":"improvise-6jk","type":"parent-child","created_at":"2026-09-25T01:04:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-dw2d","title":"core.md and walk_open_runs' doc call it 'the one reading of the markers', but prune_axis_items reads them a second time through open_at","description":"Found reviewing PR #250 at cfa8bf1e (improvise-e265). context/code/core.md (e265 section) and the doc comment on layout.rs walk_open_runs say it is 'the one reading of the markers'. prune_axis_items keeps its own reading: its input loop applies the same truncate/resize rules through open_at (OpenRuns), because it needs the header entries it moves, not only their positions. So core has two readers of the AxisEntry marker rules (three counting AxisFrame::groups on the wire type). Not a bug today: the enumeration judges prune's output through walk_open_runs, so a drift between the two fails pruning_keeps_every_surviving_rows_runs_at_every_level. Fix either the claim (the one reading for lookups; the pruner tracks its input by the same rules and the enumeration holds them together) or the code (prune reads via walk_open_runs over the undrained slice and clones items[at]).","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T06:53:15Z","created_by":"agent-review-250","updated_at":"2026-09-24T06:53:15Z","dependencies":[{"issue_id":"improvise-dw2d","depends_on_id":"improvise-e265","type":"discovered-from","created_at":"2026-09-24T06:53:21Z","created_by":"agent-review-250","metadata":"{}"},{"issue_id":"improvise-dw2d","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:20Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-07i0","title":"spanned_values' admits filter never re-checks base coordinates: a narrowing bug is a wrong answer, not a slow one","description":"From review of PR #239 at 500d4609 (improvise-jq0m). DataStore::spanned_positions chooses between base-key narrowing and per-item narrowing; admits() applies the Exact length check and span membership but not the base key's coordinates, so correctness rests entirely on which narrowing ran. A per-item path that drops the base is caught only by two DataStore-level tests, nothing above. Suggested: admits also checks base coordinates (cheap), turning any future narrowing bug into a speed cost only.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-24T02:16:39Z","created_by":"orchestrator","updated_at":"2026-09-24T02:16:39Z","dependencies":[{"issue_id":"improvise-07i0","depends_on_id":"improvise-felo","type":"parent-child","created_at":"2026-09-25T01:05:17Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-gc9t","title":"check-ci-graph refuses a BOM-prefixed workflow and crashes with a traceback on an undecodable one (both fail closed)","description":"## Summary\nFound in adversarial delta review of PR #228 at head 1bec7c0f (xpzh). Both issues fail closed, not open, so this is a P4 nit.\n\n`read_workflow` opens the workflow with `encoding=\"utf8\"`:\n1. **A BOM at the start of the file (U+FEFF) is refused.** It stays on line 1, so `_TOP_KEY` rejects `name: CI` with \"line 1 is a key or `needs:` value this gate cannot read\". go-yaml and PyYAML read that file normally, with `gate.if == false`. The gate refuses a legal workflow loudly.\n2. **An undecodable file crashes with a traceback.** Invalid UTF-8, or a whole-file UTF-16 workflow (which go-yaml and PyYAML both read), raises an uncaught `UnicodeDecodeError`. The exit code is 1, so CI fails, but there is no named complaint.\n\n## Fix\nUse `encoding=\"utf-8-sig\"`, which strips a leading BOM only; a mid-line BOM is invalid YAML and all three parsers reject it. Catch `UnicodeDecodeError` in `main`, and name the file and the byte offset. Add one file case for each.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-24T02:07:38Z","created_by":"agent-review-tooling-delta","updated_at":"2026-09-24T02:07:38Z","labels":["adversarial-review","pr-228"],"dependencies":[{"issue_id":"improvise-gc9t","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:47Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-vav0","title":"Shipped-document walk is polluted by a trace2 target set in config","description":"The shipped-document walk's `git()` in tests/shipped_documents_load.rs does not null the global or system config. That is deliberate, since `every_shipped_document_loads` reads the real checkout. Trace2 can also be enabled from config: `trace2.eventTarget`, `trace2.normalTarget` and `trace2.perfTarget`, which git reads from system and global config. Pointed at `/dev/stdout`, these write into `ls-files -z`'s output exactly as the `GIT_TRACE*` variables did (improvise-2iws). Removing the environment variables, which is what PR #276 does, does not reach config.\n\n## Steps to Reproduce\nAt PR #276 head ddcdf93a (git 2.55.0), write a config file containing `[trace2] eventTarget = /dev/stdout`. Run the test binary with `GIT_CONFIG_GLOBAL=\u003cfile\u003e` (or `GIT_CONFIG_SYSTEM`) and `every_shipped_document_loads --exact`. It panics with `examples/demo.improv does not load: Cannot open \u003croot\u003e/{\"event\":...`. `a_gzipped_document_is_walked` also fails. `normalTarget` pollutes stdout the same way.\n\n## Fix candidate\nSet `GIT_TRACE2`, `GIT_TRACE2_EVENT` and `GIT_TRACE2_PERF` to `0` in `untraced_git()` instead of removing them. The env var outranks the config target. Verified: with the config above plus `GIT_TRACE2_EVENT=0`, or with `normalTarget` plus `GIT_TRACE2=0`, `ls-files -z` prints only the path.\n\n## Acceptance Criteria\n- A child-process test sets `trace2.eventTarget` (and `normalTarget`) to `/dev/stdout` in a `GIT_CONFIG_GLOBAL` file and re-runs the walk tests, which pass.\n- The same test goes red when the `=0` overrides are dropped.\n\nSame severity as improvise-2iws: the failure is loud and the setting is self-inflicted. Found in the adversarial review of #276.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T01:08:19Z","created_by":"agent-review-255","updated_at":"2026-09-24T21:44:50Z","started_at":"2026-09-24T01:11:22Z","closed_at":"2026-09-24T21:44:50Z","close_reason":"Landed in main via #276 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-2iws","title":"Shipped-document walk parses trace output sent to stdout as paths","description":"tests/shipped_documents_load.rs's `git()` strips repo-local vars (--local-env-vars) and, since improvise-a0i3, five pathspec/attribute settings. The GIT_TRACE family is also process-wide and is not stripped. When any of these points at stdout, git writes trace lines into the `ls-files -z` output, and `shipped_documents` parses them as paths:\n- `GIT_TRACE=/dev/stdout`\n- `GIT_TRACE2_EVENT=/dev/stdout`\n- `GIT_TRACE_PERFORMANCE=/dev/stdout`\n\n## Steps to Reproduce\nAt PR #269 head 4cef40c9 (git 2.55.0), run the test binary with `GIT_TRACE=/dev/stdout every_shipped_document_loads --exact`. It panics with \"examples/demo.improv does not load: Cannot open \u003croot\u003e/17:39:40.198221 git.c:502 trace: built-in: git ls-files ...\". The same three settings also fail all four scratch-repo shape tests.\n\nThe failure is loud, not a silent pass. The setting is also self-inflicted: `=1` (stderr) does not break anything. So this is low priority.\n\n## Acceptance Criteria\n- The walk's git removes every `GIT_TRACE*` variable. A prefix match is needed because the family is open-ended.\n- A child-process test covers at least `GIT_TRACE=/dev/stdout` and `GIT_TRACE2_EVENT=/dev/stdout`, and fails with the removal dropped.\n\nFound in the adversarial review of #269.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:39:56Z","created_by":"agent-review-255","updated_at":"2026-09-24T21:44:50Z","started_at":"2026-09-24T00:50:14Z","closed_at":"2026-09-24T21:44:50Z","close_reason":"Landed in main via #276 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-0r01","title":"innermost_group walks and clones the whole axis per z keypress since open_runs became the one reader (Codex P2 on #263)","description":"PR #263 (improvise-6u6s) at 2ea24074 made innermost_group call open_runs, which clones the open-run stack for EVERY data item on the axis and keeps all of them, then indexes one. The previous version stopped at idx with O(depth) state. ChatGPT Codex's P2 on #263 is factually right. The cost is O(items x depth) small allocations per z/gz keypress, and z/gz are the only production callers, via row_group_for and col_group_for. On the same keypress, landing() builds a fresh pruned GridLayout and apply_effects rebuilds the layout, and each of those costs O(items) or more with a String allocation per tuple. So the regression is dominated rather than noticeable, which is why it is P4. Fix without losing the single reader: have open_runs yield lazily, or take a stop index, so innermost_group stops at idx and the tests still collect the whole table.","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:34:27Z","created_by":"agent-review-263","updated_at":"2026-09-24T21:44:42Z","started_at":"2026-09-24T00:36:00Z","closed_at":"2026-09-24T21:44:42Z","close_reason":"Landed in main via #273 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-ybum","title":"browser_round_trip's tile_to and column walk are unbounded loops: a stuck tile/column cursor hangs the binary instead of failing","description":"Found in the adversarial review of PR #256 at head eb146bcc. This predates #256.\n\nIn crates/improvise-wasm-client/tests/browser_round_trip.rs, the helper `tile_to` (line ~434) and the column walk in `the_tile_bar_route_into_records_mode_does_not_strand_the_user` (line ~475) are unbounded `while` loops that press l or h until an index is reached. A resolver fault that stops the cursor short of the target never ends the loop. The test binary hangs; it does not fail.\n\nMeasured: rival `left_clamp_one` changes `tile_cursor`'s lower clamp from `clamp(0, ..)` to `clamp(1, ..)`. With it, that test ran past 17 minutes and had to be killed. The fault is still detected, by two resolve.rs unit tests. But in CI a hang costs the job timeout and names no test, and locally it looks like a slow build.\n\nFix: bound each loop, for example by the tile count or the column count plus one. Assert progress on every press, so that a cursor which does not move fails with the index it is stuck on.\n","status":"closed","priority":4,"issue_type":"task","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:20:08Z","created_by":"agent-review-256","updated_at":"2026-09-24T21:45:20Z","started_at":"2026-09-24T00:21:21Z","closed_at":"2026-09-24T21:45:20Z","close_reason":"Landed in main via #271 (reviewed; merged 2026-09-24).","labels":["adversarial-review","pr-256"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-aopa","title":"resolve.rs tests fold client-composed commands through fold (the host door), latent like xoxh's renaming test","description":"Found in the adversarial review of PR #259 (improvise-xoxh) at c349d14d. xoxh moved one test's client-composed commands off `ClientState::fold`, the door for what the HOST sends, and onto `fold_outgoing`. Eight test sites in crates/improvise-protocol/src/resolve.rs still fold what `resolve` composed through `fold`: lines 2296 (the Heard classifier), 2356 and 2523 (prefix arming and sequences), 3452 (press_folded), 3612, 3730, 3828 (arm) and 3874.\n\nToday this is latent. None of those tests read `in_flight` or `settled`, and `apply`, which moves `view`, is the same code behind both doors. But a composed `Command::Model` folded through `fold` never enters `in_flight` as awaited. Any future assertion on rollback or settle state in these helpers would therefore describe a client that cannot exist. That is the vacuity xoxh found in the renaming test.\n\nFix candidate: fold resolved commands with `fold_outgoing(\u0026commands)`, one frame per key, which is what web/app.js and lib.rs do. Rival: put `fold` back and assert that some awaited entry or settle-state check goes red. Otherwise the change is untestable, and that is an argument for P4.\n","status":"closed","priority":4,"issue_type":"task","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-24T00:14:27Z","created_by":"agent-review-259-260","updated_at":"2026-09-24T21:45:05Z","started_at":"2026-09-24T00:20:20Z","closed_at":"2026-09-24T21:45:05Z","close_reason":"Landed in main via #270 (reviewed; merged 2026-09-24).","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1x9h","title":"Browser: a status written while a minibuffer is open hides after TOAST_MS (6s) with the prompt still open — su4u's bug on the toast timer (latent behind cl7y)","description":"Found in the adversarial review of PR #257 (improvise-su4u) at head 2b1c5bf7. The author noted this in the PR body and in `context/code/tui.md` and did not file it.\n\nIn the browser, `status_msg` is shown only as a toast. `web/shell.js::createToasts` hides it after `TOAST_MS` (6000 ms) whatever the mode. The minibuffer element (`web/app.js`, `mb.prompt + mb.value`) never shows the message. So a message that belongs beside an open minibuffer, such as a refusal the user is still fixing, disappears on a timer while the prompt is still open. This is su4u's bug with a different expiry mechanism.\n\nIt is LATENT for su4u's headline case, and that is the reason to file it rather than fix it now. A formula refusal never reaches a browser at all. The client folds `CommitBuffer`'s optimistic \"Formula added\" and `ChangeMode(FormulaPanel)`. The host's \"Formula error\" is a ViewOnly write to the host's own slice. `crates/improvise-wasm-client/tests/lockstep.rs::a_formula_the_client_cannot_parse_diverges_on_the_status_line` still asserts that divergence at 2b1c5bf7, although improvise-cl7y is closed. So this ticket becomes live when a host-addressed status reaches the composer, as `context/code/clients.md` recommends for cl7y, or for any client-composed refusal that keeps a minibuffer open.\n\nAcceptance: a message written while a text-entry mode stays open stays visible until the user leaves that mode or a newer message replaces it. The rule should be the one #257 gives the TUI (see improvise-sx7h for where that rule's mode-equality proxy is wrong). The test is a `web/tests` case on `createToasts` or its caller, plus a Playwright check if the DOM wiring changes.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-qnry-ybum","owner":"el-github@elangley.org","created_at":"2026-09-24T00:01:32Z","created_by":"agent-review-257","updated_at":"2026-09-24T21:44:47Z","started_at":"2026-09-24T00:40:40Z","closed_at":"2026-09-24T21:44:47Z","close_reason":"Landed in main via #279 (reviewed; merged 2026-09-24).","labels":["adversarial-review","area:shared-frontend","pr-257"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mgis","title":"OpenedEditor::awaiting is serde(default), against 1wl0's doctrine, and unpinned: deleting the attribute leaves the workspace green (#227)","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nOpenedEditor::awaiting carries #[serde(default)]. OpenedEditor lives in ViewState, so it travels in the Snapshot. `awaiting: false` is not an absence. It is the claim \"the seed is settled; judge a commit against `seed`\".\n\nA peer that lost the field would deliver an editor that was really awaiting as a settled one with seed \"\". The client would never settle it: the editor stays empty, and an Enter is \"untouched\" and writes nothing. It fails silently. That is the exact shape protocol.md §3a / improvise-1wl0 rejected for AxisFrame::first: \"a missing field is now a loud parse failure at the transport, where a wire-format skew belongs. No skew ships today, both ends being one workspace at one commit.\"\n\nIt is also unpinned. Deleting the attribute leaves the whole workspace green (1950 passed; only my unrelated probes failed). So it is neither the mechanism of any test nor argued in protocol.md.\n\nFix: drop the attribute. Alternatively, keep it with a written argument for why this field differs from 1wl0, plus a test that loads an old-shape OpenedEditor as awaiting=false (per doctrine(populate_before_roundtrip)).\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:45Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:56Z","started_at":"2026-09-23T18:29:20Z","closed_at":"2026-09-24T21:44:56Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4cb5","title":"#227: an awaited seed settles after Esc, writing the landed cell's text into the edit buffer in Normal mode","description":"Found by the adversarial delta re-review of PR #227 at head baabe1a9.\n\nClientState::settle_awaited_seed checks that the editor is awaiting and untouched. It does not check that the mode is still an editing mode. Esc's spine is [clear-buffer edit, enter-mode normal], and entered(Normal) leaves opened_editor in place. So an Esc pressed before the answer arrives leaves an awaiting editor behind. When the answer lands, the client writes the landed cell's seed into the \"edit\" buffer and sets opened_editor = on(seed), all while in Normal mode.\n\nProbe: a real Host and ClientState. Keys i 9 Enter, then Esc before the Enter is answered, then both answers are delivered. The client ends at mode=Normal, edit=\"2\", opened=on(\"2\"). The TUI has edit=\"\" after Esc. This is a lockstep buffer divergence. Its visible effect is small, since `i` re-seeds explicitly. But the buffer is now present, so settle_editor's absent-buffer (ktlk) path will not seed a later editor that arrives without a buffer.\n\nFix: also require self.view.mode.is_editing() in `waiting`. Clearing opened_editor on leaving an editing mode would work too.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-227-fixes","owner":"el-github@elangley.org","created_at":"2026-09-23T18:26:43Z","created_by":"agent-review-227c","updated_at":"2026-09-24T21:44:55Z","started_at":"2026-09-23T18:29:19Z","closed_at":"2026-09-24T21:44:55Z","close_reason":"Landed in main via #227 (reviewed; merged 2026-09-24).","labels":["adversarial-review","browser","pr-227"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h3cv","title":"command.md says tile select's fallback is always _Measure, but in _Drill _Measure is on Filter and the Label/_Index ranks are live and untested","description":"Found reviewing PR #242 (improvise-bqm0/q69m/52od) at head de1524c0.\n\n`context/code/command.md` (the ranking paragraph #242 adds) says \"In practice the\nfallback is always `_Measure`, which every document has.\" That is false in the `_Drill`\nview. `drill_steps` puts every coordinate of the drilled key on `Axis::Filter`,\n`_Measure` included when the cell is a raw measure, and `tileable_cats` drops Filter.\nSo in a drill `_Measure` is not a tile at all.\n\nMeasured with a throwaway probe: Region on Row, `_Measure` on Column, a Label `Note`\non None, one cell, then `\u003e`. The `_Drill` tiles are `[_Index Row VirtualIndex,\n_Dim Column VirtualDim, Note None Label]`, and `opening_tile` returns 2. `T Enter`\nthen puts `Note` on Row next to `_Index`. With no Label and every regular category\ndrilled, only `_Index`/`_Dim` remain, so tile select opens on `_Index`. That is the\noriginal oplp shape, and there is nothing better to open on.\n\nSo the Label rank (and the `_Index` fallback) are live exactly in a drill. No lockstep\nor unit test drives that case: the unit test covers the Label rank with a hand-built\ntile list, and the two lockstep tests use a records view and a view with no regular\ncategory. Fix the sentence, and decide whether a drill case belongs in lockstep.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:14:45Z","created_by":"agent-review-233-242","updated_at":"2026-09-23T16:14:45Z","labels":["adversarial-review","docs","pr-242"],"dependencies":[{"issue_id":"improvise-h3cv","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:16Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-xbgm","title":"The client bundle's nontrapping-float-to-int flag is blamed on typed(); bisection says CellValue::edit_text() (#227)","description":"#227 (head a093d126) adds `--enable-nontrapping-float-to-int` to improvise-wasm-client's wasm-opt flags. Its Cargo.toml comment (\"needed here once the client parses typed text\") and the a093d126 commit message both blame `typed()`. Bisected by the adversarial review, building the client bundle with the flag removed:\n- gh/main 5932ad74: builds.\n- #227 head: wasm-opt rejects `i64.trunc_sat_f64_s`.\n- #227 head with `landed_seed` changed to `typed(text).map(|_| text.to_string())` (typed() kept, edit_text() dropped): builds.\n\nSo the instruction comes from `CellValue::edit_text()`, which formats an f64. It does not come from `typed()`, which is already in the bundle through `typed_cell_command`. The flag itself is right: the flag does not change codegen, rustc already emits the instruction, and the worker bundle has needed the flag since gotcha 45. Only the stated reason is wrong. Correct the comment so the next reader does not go looking in the parser.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T16:01:53Z","created_by":"agent-review-227b-240","updated_at":"2026-09-23T16:01:53Z","labels":["adversarial-review","browser","pr-227"],"dependencies":[{"issue_id":"improvise-xbgm","depends_on_id":"improvise-fhj0","type":"parent-child","created_at":"2026-09-25T01:05:42Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-9n2z","title":"cli.md §10 says every key-path decline lands in dispatch's arg-error arm, including i; i never reaches it","description":"Found reviewing PR #238 at c9bbe32e. context/code/cli.md (the 'A bound key whose command will not build is a refusal' paragraph) says dispatch's Err arm 'is where every key-path decline lands, including p, x and i onto an aggregate, a fold, a coordinate column or inside a drill'. Measured through App::dispatch_key: p/x/yy/\u003e/Enter/Tab in Editing on an aggregate or fold do land there (status carries the '\u003ccmd\u003e: ' prefix run() adds). i does not: edit-or-drill is register_pure, its constructor only parses the mode, and its fold decline comes from EditOrDrill::execute (status 'Collapsed group — press z to expand it, then edit an item', no prefix). i on an aggregate drills rather than declines. And 'every key-path decline' is false in general: the three panel.rs declines this same PR converts are execute-side. Fix: name the constructors that can err on context (value_cell_key for paste/clear-cell, cell_key for yank, unfolded_cell_key for drill-into-cell, committable_cell_key for commit-cell-edit/commit-and-advance-right) and drop i.","notes":"Blocked on #238: the sentence exists only on #238's branch. Replacement text proposed in PR #249's body (ab44516). Apply on #238's branch at its next re-merge, or as follow-up after #238 merges.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:58:05Z","created_by":"agent-review-238","updated_at":"2026-09-23T18:15:52Z","dependencies":[{"issue_id":"improvise-9n2z","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:16Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-bj61","title":"lasd's debug_assert reads the batch-wide refusal flag: a Model effect that aborts without refusing after an earlier refusal in the same batch passes it","description":"Found in adversarial delta review of PR #229 (head 66b6a973). App::apply_effects asserts matches!(kind, ViewOnly) || self.batch_refused when abort_effects is set. batch_refused is set by ANY earlier refusal in the batch, so the check asks 'has this batch refused' where it means 'did this effect refuse'. Demonstrated: with on_view's missing-view branch changed to write the status and abort without refusing (the lasd rival), the batch [refuse(\"earlier\"), SetAxis{view: Named(\"NoSuchView\")}] aborts (last_batch_aborted() == true) and the assert does not fire. The same rival alone fails two tests through the assert. The residual is stated in context/code/command.md (the lasd paragraph) but has no ticket. Cheap fix shape: snapshot a refusal counter (or the flag) before each effect and assert the effect itself refused. Low priority: no current Model effect aborts without refusing; this only narrows what a future fault can slip past. Related: improvise-lasd, improvise-we1a.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:46:02Z","created_by":"agent-review-222b-229b","updated_at":"2026-09-23T15:46:02Z","dependencies":[{"issue_id":"improvise-bj61","depends_on_id":"improvise-31oe","type":"parent-child","created_at":"2026-09-25T01:05:11Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-4vw8","title":"A click_formula doc comment still says the formula panel binds no Enter, after km71 made a click open the editor","description":"Found reviewing PR #214 at f3fb83ee. crates/improvise-wasm-client/src/lib.rs:137-138, on Client::click_formula, reads 'Navigation only — that panel binds no Enter (improvise-iioi)'. Since improvise-km71 the formula panel binds Enter to edit-formula-at-cursor, and resolve.rs::click_formula_row ends with edit_formula_at, so a click opens the editor. The comment is the wasm-bindgen surface's only description of what the page's click does. Fix: say the click focuses, moves the cursor and opens the editor on that row, as click_formula_row's own comment does.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:28:26Z","created_by":"agent-review-214b-223","updated_at":"2026-09-23T16:00:12Z","closed_at":"2026-09-23T16:00:12Z","close_reason":"Fixed in PR #214 (formula editing, user-requested), merged after adversarial review (77f059c, then the kb1d delta at f3fb83e). Later changes were conflict-only gh/main merges, the doc-comment fix, and serde re-added for FormulaId only (orchestrator ruling, after tt22).","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-52od","title":"No test separates opening_tile's 'first regular' rule from 'first tile on Row'","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. The fixture has two derivations agree. In lockstep's furnished_host, Region is both the first Regular tile and the first tile on Row. In web/demo.improv, Product is both too. Injected rival: opening_tile = position(|t| t.axis == Axis::Row).unwrap_or(0), which needs no wire field at all. Result: cargo test --workspace --no-fail-fast was 1858 passed, 0 failed. The rules differ observably in records mode, where _Index is on Row: the rival opens on _Index, and T Enter drops the user out of records mode. They also differ when the first regular category is on Page or Column. Guard: a unit test of opening_tile in render.rs, with a Row-placed virtual before a Page- or Column-placed regular, or a lockstep case started in records mode.","status":"in_progress","priority":4,"issue_type":"task","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:38Z","created_by":"agent-review-231","updated_at":"2026-09-23T15:27:34Z","started_at":"2026-09-23T15:27:34Z","lease_expires_at":"2026-09-23T15:32:34Z","heartbeat_at":"2026-09-23T15:27:34Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-swsi","title":"TUI help footer drops the close keys first below 78 columns (PR #220 footer)","description":"Found in adversarial review of PR #220 (improvise-17ak) at head 5e37973f.\n\nThe help popup footer is now \" page N of M \" + hint_line(Help), truncated in display cells with the LEFT kept (ui::text::truncate). The hint is \"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close\" (57 cells), so the line is 74 cells and the popup's inner width is (terminal width - 4) below 91 columns. Rendered with a scratch test (not committed):\n- 80x24: whole hint fits, 2 cells spare.\n- 60x24: \"...S-Tab/h/p/←:prev-…\" — the close keys are gone.\n- 40x20: \"...Tab/l/n/→:next-pag…\" — prev and close gone.\nSo below 78 columns the escape hatch is the first thing dropped. The old hand-written footer (\"page 1 of 5 l/Tab: next q/Esc: close\", ~42 cells) kept the close keys down to ~46 columns. Gotcha 50's rule is to name which side yields; here the close keys are the one segment that must not.\n\nSecond, smaller: the old footer was page-aware (no \"next\" on the last page, no \"prev\" on the first); the derived hint advertises both on every page, and HelpPageNext/Prev clamp, so on page 5 \"next-page\" is a no-op key. Not wrong, just less useful.\n\nThe PR's test renders at 140x30 and cannot see either. A fix might put the close keys first, or truncate the page indicator before the hint, and pin a narrow width (e.g. 60) in a test.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:13Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:13Z","labels":["adversarial-review","help","pr-220","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-52od","title":"No test separates opening_tile's 'first regular' rule from 'first tile on Row'","description":"Found reviewing PR #231 (improvise-oplp), head 2383c37f. The fixture has two derivations agree. In lockstep's furnished_host, Region is both the first Regular tile and the first tile on Row. In web/demo.improv, Product is both too. Injected rival: opening_tile = position(|t| t.axis == Axis::Row).unwrap_or(0), which needs no wire field at all. Result: cargo test --workspace --no-fail-fast was 1858 passed, 0 failed. The rules differ observably in records mode, where _Index is on Row: the rival opens on _Index, and T Enter drops the user out of records mode. They also differ when the first regular category is on Page or Column. Guard: a unit test of opening_tile in render.rs, with a Row-placed virtual before a Page- or Column-placed regular, or a lockstep case started in records mode.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T15:23:38Z","created_by":"agent-review-231","updated_at":"2026-09-25T01:00:01Z","closed_at":"2026-09-25T01:00:01Z","close_reason":"Landed in #242 (66bd3fcb on gh/main)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-swsi","title":"TUI help footer drops the close keys first below 78 columns (PR #220 footer)","description":"Found in adversarial review of PR #220 (improvise-17ak) at head 5e37973f.\n\nThe help popup footer is now \" page N of M \" + hint_line(Help), truncated in display cells with the LEFT kept (ui::text::truncate). The hint is \"Tab/l/n/→:next-page S-Tab/h/p/←:prev-page ?/Esc/q:close\" (57 cells), so the line is 74 cells and the popup's inner width is (terminal width - 4) below 91 columns. Rendered with a scratch test (not committed):\n- 80x24: whole hint fits, 2 cells spare.\n- 60x24: \"...S-Tab/h/p/←:prev-…\" — the close keys are gone.\n- 40x20: \"...Tab/l/n/→:next-pag…\" — prev and close gone.\nSo below 78 columns the escape hatch is the first thing dropped. The old hand-written footer (\"page 1 of 5 l/Tab: next q/Esc: close\", ~42 cells) kept the close keys down to ~46 columns. Gotcha 50's rule is to name which side yields; here the close keys are the one segment that must not.\n\nSecond, smaller: the old footer was page-aware (no \"next\" on the last page, no \"prev\" on the first); the derived hint advertises both on every page, and HelpPageNext/Prev clamp, so on page 5 \"next-page\" is a no-op key. Not wrong, just less useful.\n\nThe PR's test renders at 140x30 and cannot see either. A fix might put the close keys first, or truncate the page indicator before the hint, and pin a narrow width (e.g. 60) in a test.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T15:05:13Z","created_by":"agent-review-219-220","updated_at":"2026-09-23T15:05:13Z","labels":["adversarial-review","help","pr-220","tui"],"dependencies":[{"issue_id":"improvise-swsi","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:30Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-rtud","title":"command.md says every refuse_and_stop site has a key-path table case; load, import-json absent.json and AddFormula parse do not","description":"Found by the adversarial review of PR #213 at 3929196.\n\nAfter #213, context/code/command.md (the \"Every refusal site has a test\" bullet) says that each\n`refuse_and_stop` site needs a case in both tables: the key-path table\n`every_refusal_site_refuses_its_failure_and_only_its_failure` for its refusal, and\n`a_failed_load_or_import_does_not_run_the_quit_after_it` for its abort.\n\nThat is not what the code does. Three `refuse_and_stop` sites have no case in the key-path table:\n- `LoadModel` (`load absent.improv`)\n- `ImportJsonHeadless`'s JSON cannot-read arm (`import-json absent.json`)\n- `AddFormula`'s parse-error arm\n\nEach is still pinned. A per-site sweep reverting each one to a status-only write fails:\n- `LoadModel` and the JSON cannot-read arm only in\n `a_failed_load_or_import_does_not_run_the_quit_after_it`. Its refusal is caught by the assertion\n labelled \"setup:\", which checks `last_batch_refused() \u0026\u0026 status contains arm`.\n- `AddFormula`'s parse-error arm only in\n `a_formula_that_does_not_parse_keeps_the_user_in_the_editor_with_their_text`.\n\nTwo defects:\n1. The prose states a rule the tables do not follow. The next person will trust the rule and not\n check.\n2. Two refusals are held by an assertion that calls itself setup. A later edit that \"simplifies\" the\n setup check, for example to the status text alone, would unpin them with nothing to say so.\n\nFix: add the three cases to the key-path table, each with a succeeding twin. Or reword the prose to\nname where each refusal half is actually held, and stop calling that assertion a setup check.\n","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:51:53Z","created_by":"agent-review-213","updated_at":"2026-09-23T15:15:48Z","started_at":"2026-09-23T14:55:58Z","closed_at":"2026-09-23T15:15:48Z","close_reason":"Fixed in PR #225 (re-landed after missing #213's merge), merged as cb60166 after adversarial review at 7da905c.","labels":["adversarial-review","pr-213"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-we1a","title":"Host::mutating_frame's || last_batch_aborted() arm is unwitnessed: removing it leaves the workspace green","description":"Found by the adversarial review of PR #213 at 3929196 (the author flagged it; the reviewer confirmed it).\n\n`Host::mutating_frame` (crates/improvise-command/src/ui/host.rs) rejects a frame on\n`last_batch_refused() || last_batch_aborted()`. Deleting the `|| last_batch_aborted()` arm leaves\n`cargo test --workspace --no-fail-fast` fully green at 3929196. The arm is unwitnessed.\n\nWhy nothing can witness it today: every abort a wire ModelCommand can reach also refuses. The Model\neffects that set `abort_effects` are AddCategory (refuse + abort) and the `refuse_and_stop` sites\n(AddFormula parse, LoadModel, ImportJsonHeadless). `AbortChain` aborts without refusing, but it is\nViewOnly and no ModelCommand maps to it. So the arm is live only if a future Model effect aborts\nwithout refusing. In that case, dropping the arm would let the frame be accepted after one command\napplied only part of its effects, because an abort stops only that command's own batch.\n\nChoose one:\n1. Keep the arm and pin the invariant that makes it redundant today: every Model effect that aborts\n also refuses. For example, over the wire table's failure fixtures, assert\n `aborted implies refused`. Then say in context/code/command.md that the arm is defence for a\n future effect.\n2. Make it unrepresentable. For example, let Model effects set `abort_effects` only through\n `refuse_and_stop`. Then remove the arm.\nDo not leave an arm that no test sees in either direction.\n","status":"in_progress","priority":4,"issue_type":"task","assignee":"agent-uo2p","owner":"el-github@elangley.org","created_at":"2026-09-23T14:51:37Z","created_by":"agent-review-213","updated_at":"2026-09-23T14:55:57Z","started_at":"2026-09-23T14:55:57Z","lease_expires_at":"2026-09-23T15:00:57Z","heartbeat_at":"2026-09-23T14:55:57Z","labels":["adversarial-review","pr-213"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-we1a","title":"Host::mutating_frame's || last_batch_aborted() arm is unwitnessed: removing it leaves the workspace green","description":"Found by the adversarial review of PR #213 at 3929196 (the author flagged it; the reviewer confirmed it).\n\n`Host::mutating_frame` (crates/improvise-command/src/ui/host.rs) rejects a frame on\n`last_batch_refused() || last_batch_aborted()`. Deleting the `|| last_batch_aborted()` arm leaves\n`cargo test --workspace --no-fail-fast` fully green at 3929196. The arm is unwitnessed.\n\nWhy nothing can witness it today: every abort a wire ModelCommand can reach also refuses. The Model\neffects that set `abort_effects` are AddCategory (refuse + abort) and the `refuse_and_stop` sites\n(AddFormula parse, LoadModel, ImportJsonHeadless). `AbortChain` aborts without refusing, but it is\nViewOnly and no ModelCommand maps to it. So the arm is live only if a future Model effect aborts\nwithout refusing. In that case, dropping the arm would let the frame be accepted after one command\napplied only part of its effects, because an abort stops only that command's own batch.\n\nChoose one:\n1. Keep the arm and pin the invariant that makes it redundant today: every Model effect that aborts\n also refuses. For example, over the wire table's failure fixtures, assert\n `aborted implies refused`. Then say in context/code/command.md that the arm is defence for a\n future effect.\n2. Make it unrepresentable. For example, let Model effects set `abort_effects` only through\n `refuse_and_stop`. Then remove the arm.\nDo not leave an arm that no test sees in either direction.\n","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T14:51:37Z","created_by":"agent-review-213","updated_at":"2026-09-25T01:00:01Z","closed_at":"2026-09-25T01:00:01Z","close_reason":"Landed in #229 (on gh/main)","labels":["adversarial-review","pr-213"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-28ri","title":"AGENTS.md audit prose: -d skip rationale is wrong (grep -r never follows the skills symlinks), and 'spells no id' is false","description":"Found by the adversarial delta review of PR #201 at head 141be9fc, in AGENTS.md, \"Cite a ticket ID you have read back\". The command is right; only two sentences of its prose are wrong.\n\n1. The \"The skills twice\" bullet lists .claude/skills/* among the things \"a grep -r over the same roots reads\", and says `-d skip` is what makes the skills get read once. Both BSD /usr/bin/grep and GNU grep do NOT follow a symlink they meet while recursing. The text this PR replaced said exactly that.\n - Measured on macOS: `grep -rl 'name: wave' .claude .agents` returns only `.agents/skills/wave/SKILL.md`. GNU grep gives the same answer.\n - What -d skip really does: `git ls-files` lists each symlink as a file entry. grep, handed a symlink to a directory, prints `grep: .claude/skills/wave: Is a directory` on stderr. Without the flag that is five such lines.\n - No id is ever counted twice, with or without the flag. The reason to give is \"stderr noise from the symlink entries\".\n2. \"Note the recipe's own prose deliberately spells no live or dead id\" is false. The new build-output bullet cites improvise-8haj. The artifact bullet already cited improvise-oujb before #201. Either drop the ids or drop the sentence.\n\nAcceptance: both sentences corrected, and the audit still reports \"all resolve\".\n","status":"closed","priority":4,"issue_type":"bug","assignee":"Edward Langley","owner":"el-github@elangley.org","created_at":"2026-09-23T06:28:11Z","created_by":"agent-review-201b","updated_at":"2026-09-23T15:15:50Z","started_at":"2026-09-23T06:30:17Z","closed_at":"2026-09-23T15:15:50Z","close_reason":"Fixed in PR #211, merged as 968f65a after adversarial review (delta at 41aa407).","comments":[{"id":"045ec3d8-eae0-5af9-a571-579b19cdf81f","issue_id":"improvise-28ri","author":"agent-review-211","text":"Review of PR #211 at 6ccad4b8 (agent-review-211). The \"-d skip changes no answer\" conclusion holds. I measured it: the same 3092 matches with and without the flag, under both BSD grep 2.6.0 and GNU grep 3.12. The mechanism it now gives is still wrong, though.\n\nThe recipe's grep has no -r. The .claude/skills/* symlinks reach it as command-line operands, not as something it \"meets while recursing\". So what grep -r does while recursing has no bearing on the recipe. Nothing is read twice for a simpler reason: grep without -r never reads a directory operand.\n\nThe stated rule is also false for command-line operands. GNU grep -r DOES follow a symlink named on the command line. Adding -r to the recipe gives 3107 matches under GNU against 3092 under BSD. Those 15 are the skills read a second time through .claude.\n\nAnd \"no id is ever counted twice\" is moot anyway, because the pipeline goes through sort -u.\n\nSuggested wording: \"The grep has no -r, so a directory operand is never read, and -d skip only silences the 'Is a directory' lines. The output goes through sort -u, so a duplicate could not change the answer anyway.\"\n","created_at":"2026-09-23T14:48:01Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1} {"_type":"issue","id":"improvise-hoxy","title":"names_its_column accepts CellId::Derived on any measure column, and records_app has no formula, so the arm only loosens the enumeration","description":"Found reviewing PR #206 (improvise-w1bu). In a_coordinate_cell_is_named_rather_than_left_as_no_cell, the helper's (Measure(_), Derived{..}) arm returns true unconditionally, ignoring target and whether a formula owns the column. records_app() defines no formula, so every correct Derived is impossible in this fixture and the arm can only admit a bug. Injected at b22f6e53: RecordCol::Measure(m) =\u003e return Some(CellId::Derived{target: m.clone()}) in cell_id. This test PASSES; the fault is caught elsewhere (3 cell_identity tests in improvise-command lib, 4 in wasm-client tests/lockstep.rs), so no bug ships, but the enumeration the ticket was meant to strengthen does not state that half of the contract. Fix: drop the Derived arm (fixture has no formulas), or add a formula-owned measure to the fixture and require Derived exactly there with target == the formula's.","status":"closed","priority":4,"issue_type":"task","assignee":"agent-viewhist","owner":"el-github@elangley.org","created_at":"2026-09-23T05:50:00Z","created_by":"agent-review-206","updated_at":"2026-09-23T06:35:10Z","started_at":"2026-09-23T05:51:47Z","closed_at":"2026-09-23T06:35:10Z","close_reason":"Fixed in PR #206, merged as 2a52888 after adversarial review at b22f6e5. hoxy was the reviewer-requested test fix; later merges touched evidence only. Follow-up: improvise-v8ab (#207).","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-17ak","title":"TUI help popup footer hand-writes its paging keys (l/Tab, h/S-Tab, q/Esc): a second key table no guard reads","description":"Found in adversarial review of PR #180 at head f2488276 while checking whether anything still teaches paging after the Welcome tip was removed.\n\nsrc/ui/help.rs render_footer hand-writes the help popup's paging keys: \"l/Tab: next\" on page 1, \"h/S-Tab: prev\" on the last page, \"h: prev l: next\" between, and \"q/Esc: close\". They are true today (keymap.rs binds l, n, Right, Tab to help-page-next; h, p, Left, BackTab to help-page-prev; q, Esc, ? to enter-mode normal), but they are a second key table nothing checks: every_hint_key_resolves_in_its_mode cannot see a string literal in a widget (improvise-rr8n / improvise-wk18 name the same blind spot), and a rebinding would leave the footer lying while the status row below it (hint_line(Help)) told the truth.\n\nPer improvise-rr8n's rule (keep the table derived from the thing it describes), the footer's key half should come from help::hints(\u0026AppMode::Help) / keys_for, or be dropped in favour of the status row the popup already leaves visible. The page indicator (\"page N of M\") is not a key table and can stay.\n","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-docs-wave","owner":"el-github@elangley.org","created_at":"2026-09-23T05:21:59Z","created_by":"Edward Langley","updated_at":"2026-09-23T15:15:47Z","started_at":"2026-09-23T14:44:18Z","closed_at":"2026-09-23T15:15:47Z","close_reason":"Fixed in PR #220, merged as c7e4854 after adversarial review at 5e37973. Follow-ups: improvise-swsi, improvise-cq3d.","labels":["help","tui"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-e958","title":"source_scan panics on #![cfg(test)] preceded by another inner attribute such as #![allow(..)]","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). This is a loud failure, not a silent one, so it is low priority.\n\n`test_only_items` accepts an inner `#![cfg(test)]` only when everything before it in the blanked code is whitespace. A file that heads itself with another inner attribute first is ordinary Rust, but it **panics** with \"an inner test-only cfg could not be bounded\":\n\n```\n//! doc\n#![allow(dead_code)]\n#![cfg(test)]\nfn t() {}\n```\n\nThe fix is to let \"leading\" mean \"preceded only by whitespace and inner attributes\". No file in the workspace has this shape today; all 126 .rs files were scanned without a panic.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:29Z","labels":["adversarial-review","pr-195","protocol"],"dependencies":[{"issue_id":"improvise-e958","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-msg2","title":"The .improv writer's format_number is a second derivation of exact f64 text, and it drops the sign of -0.0 on save","description":"Found reviewing PR #194 (improvise-umi4). umi4 made CellValue::edit_text (improvise-core model/cell.rs) the one home of 'text that reads back as this f64', and its tests require Number(-0.0) to survive i then Enter bit for bit. improvise-io persistence/format.rs::format_number answers the same question separately: fract()==0 \u0026\u0026 abs\u003c1e15 writes 'n as i64', so -0.0 is written '0' and loads as +0.0, while edit_text writes '-0'. Its Debug fallback (format!(\"{n:?}\") when Display does not round-trip) is dead code, because f64 Display is always shortest round-trip (checked on 1M random finite f64s plus the extremes 1e300, 5e-324, f64::MAX, subnormals: 0 failures). Harm is tiny (only 1/x and atan2 see the sign), but it is the derivation-done-twice shape design-principles section 5 calls the defect. Suggested: format_number delegates to CellValue::Number(n).edit_text() for the non-integer arm, and the integer arm checks is_sign_negative, if the grammar's number rule accepts '-0'. Check that the grammar number rule accepts every edit_text spelling (no exponent, up to 327 chars) before delegating.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:29Z","created_by":"agent-review-194","updated_at":"2026-09-23T05:04:29Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-4zei","title":"kb-test case 25's restore-then-loss half passes against a content-hash-only ledger stamp","description":"Found reviewing PR #179 at 9472989.\n\nIn `scripts/kb-test.sh`, case 25 runs the production git-path ledger in a throwaway repo. Its name says \"...and a loss after a restore is refused\". That half does not discriminate for the property improvise-yea8 fixed, a stamp that carries file identity and not only content.\n\nBetween the `rm` and the restore, case 25 runs an `add`, so the ledger is stamped for HEAD minus X **plus the added doctrine**. The file it restores and then damages is HEAD minus X **without** that doctrine. Its bytes therefore differ from the published file, and a content-hash-only stamp refuses it too.\n\nRival injected in a scratch copy: `file_stamp` reduced to `stamp_of` alone (no inode, no mtime). The result was 44 passed, 1 failed, and only case 24 failed; case 25 passed. Case 24 still guards the mechanism on the `KB_FILE` path, so this is a coverage gap on the production path, not a hole in the tool.\n\n## Acceptance Criteria\n- In case 25, drop the intervening add, or restore and damage to bytes identical to the last publish (restore, then `grep -v` X from HEAD, which is byte-identical to the `rm` output), so that a hash-only stamp fails case 25 as well as case 24.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:17Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:28:17Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-1dzx","title":"The formula panel's empty-state line lives only in the TUI","description":"The TUI draws '(no formulas — press n to add)' in src/ui/formula_panel.rs; the browser draws an empty, visible panel. PR #173 (df2w) deliberately did not copy the string into render.rs, which would give it a second home, and context/code/clients.md now says the page 'does not yet draw' it. That is a promise with no ticket behind it, and this bead is the ticket. Fix: move the string into improvise-protocol, the way help::pages was moved, and have both front ends read it. Cosmetic: the browser's hint bar already says 'n:new'.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:47Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:10:47Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-e958","title":"source_scan panics on #![cfg(test)] preceded by another inner attribute such as #![allow(..)]","description":"Found by the adversarial review of PR #195 (improvise-59q8, head 43ba5e6). This is a loud failure, not a silent one, so it is low priority.\n\n`test_only_items` accepts an inner `#![cfg(test)]` only when everything before it in the blanked code is whitespace. A file that heads itself with another inner attribute first is ordinary Rust, but it **panics** with \"an inner test-only cfg could not be bounded\":\n\n```\n//! doc\n#![allow(dead_code)]\n#![cfg(test)]\nfn t() {}\n```\n\nThe fix is to let \"leading\" mean \"preceded only by whitespace and inner attributes\". No file in the workspace has this shape today; all 126 .rs files were scanned without a panic.\n","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","updated_at":"2026-09-23T05:13:29Z","labels":["adversarial-review","pr-195","protocol"],"dependencies":[{"issue_id":"improvise-e958","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:45Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-e958","depends_on_id":"improvise-59q8","type":"discovered-from","created_at":"2026-09-23T05:13:29Z","created_by":"agent-review-195","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-msg2","title":"The .improv writer's format_number is a second derivation of exact f64 text, and it drops the sign of -0.0 on save","description":"Found reviewing PR #194 (improvise-umi4). umi4 made CellValue::edit_text (improvise-core model/cell.rs) the one home of 'text that reads back as this f64', and its tests require Number(-0.0) to survive i then Enter bit for bit. improvise-io persistence/format.rs::format_number answers the same question separately: fract()==0 \u0026\u0026 abs\u003c1e15 writes 'n as i64', so -0.0 is written '0' and loads as +0.0, while edit_text writes '-0'. Its Debug fallback (format!(\"{n:?}\") when Display does not round-trip) is dead code, because f64 Display is always shortest round-trip (checked on 1M random finite f64s plus the extremes 1e300, 5e-324, f64::MAX, subnormals: 0 failures). Harm is tiny (only 1/x and atan2 see the sign), but it is the derivation-done-twice shape design-principles section 5 calls the defect. Suggested: format_number delegates to CellValue::Number(n).edit_text() for the non-integer arm, and the integer arm checks is_sign_negative, if the grammar's number rule accepts '-0'. Check that the grammar number rule accepts every edit_text spelling (no exponent, up to 327 chars) before delegating.","status":"open","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-23T05:04:29Z","created_by":"agent-review-194","updated_at":"2026-09-23T05:04:29Z","dependencies":[{"issue_id":"improvise-msg2","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-4zei","title":"kb-test case 25's restore-then-loss half passes against a content-hash-only ledger stamp","description":"Found reviewing PR #179 at 9472989.\n\nIn `scripts/kb-test.sh`, case 25 runs the production git-path ledger in a throwaway repo. Its name says \"...and a loss after a restore is refused\". That half does not discriminate for the property improvise-yea8 fixed, a stamp that carries file identity and not only content.\n\nBetween the `rm` and the restore, case 25 runs an `add`, so the ledger is stamped for HEAD minus X **plus the added doctrine**. The file it restores and then damages is HEAD minus X **without** that doctrine. Its bytes therefore differ from the published file, and a content-hash-only stamp refuses it too.\n\nRival injected in a scratch copy: `file_stamp` reduced to `stamp_of` alone (no inode, no mtime). The result was 44 passed, 1 failed, and only case 24 failed; case 25 passed. Case 24 still guards the mechanism on the `KB_FILE` path, so this is a coverage gap on the production path, not a hole in the tool.\n\n## Acceptance Criteria\n- In case 25, drop the intervening add, or restore and damage to bytes identical to the last publish (restore, then `grep -v` X from HEAD, which is byte-identical to the `rm` output), so that a hash-only stamp fails case 25 as well as case 24.\n","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:28:17Z","created_by":"agent-review-kb","updated_at":"2026-09-23T04:28:17Z","dependencies":[{"issue_id":"improvise-4zei","depends_on_id":"improvise-1xfb","type":"parent-child","created_at":"2026-09-25T01:05:43Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-1dzx","title":"The formula panel's empty-state line lives only in the TUI","description":"The TUI draws '(no formulas — press n to add)' in src/ui/formula_panel.rs; the browser draws an empty, visible panel. PR #173 (df2w) deliberately did not copy the string into render.rs, which would give it a second home, and context/code/clients.md now says the page 'does not yet draw' it. That is a promise with no ticket behind it, and this bead is the ticket. Fix: move the string into improvise-protocol, the way help::pages was moved, and have both front ends read it. Cosmetic: the browser's hint bar already says 'n:new'.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-23T04:10:47Z","created_by":"Edward Langley","updated_at":"2026-09-23T04:10:47Z","dependencies":[{"issue_id":"improvise-1dzx","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-jhdf","title":"only_the_edit_key_is_captioned_as_editing_the_cell matches one exact phrase, so a paraphrased edit caption on Enter passes","description":"Found reviewing PR #168 (improvise-s8h7) at 334e218. The test filters help rows on d.contains(\"Edit current cell\") and checks only those rows are keyed by edit-or-drill's keys. Injected: Normal's enter-advance caption changed to \"Edit the cell (same as i)\" — the exact lie #168 removed, reworded. Both help tests pass (2 passed). The PR's own rival (restoring \"Edit current cell (same as i)\") does fail, so the guard holds only against the literal phrase. A tighter form: assert the enter-advance row's caption names movement (or does not contain \"Edit\"/\"edit\"), i.e. pin Enter's row by its keys the way the_edit_key_row_says_it_drills_into_an_aggregate pins i's. Low priority: no caption test can stop every false sentence, but this one's name promises more than it checks.","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-helpkeys2","owner":"el-github@elangley.org","created_at":"2026-09-23T04:03:30Z","created_by":"agent-review-s8h7","updated_at":"2026-09-23T05:44:50Z","closed_at":"2026-09-23T05:44:50Z","close_reason":"Fixed in PR #180, merged after adversarial review (content reviewed at 544744c; the tip-removal delta and the evidence-only merges were reviewed at f248827). Follow-ups: improvise-1c5m, improvise-17ak.","labels":["adversarial-review","help","pr-168","testing"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-pikk","title":"format_f64 renders a large finite number as inf","description":"Found by the core bug hunt, DEMONSTRATED.\n\nformat.rs:29 round_half_away multiplies by 10^decimals before truncating, so n * factor overflows to infinity for a large finite n and any nonzero precision.\n\n format_f64(1e307, false, 2) == \"inf\"\n\nThe model holds the number correctly; the display invents an infinity. Gotcha 8 says format_f64 is the whole display path and that display rounding never reaches eval — which is why this is a wrong pixel and not a wrong number, and also why nothing else catches it.\n\nLow reachability: the shipped default format is \",.0\", whose factor is 1, so it needs both an absurd magnitude and an explicit .N format. format_f64(1.5e300, true, 9) is fine — the threshold is where n * 10^decimals exceeds f64::MAX.\n\n## Acceptance Criteria\n- round_half_away returns n unchanged when n * factor is not finite.\n- A test at a magnitude that overflows the factor, and one just below it, so the guard is not satisfied by clamping everything.\n","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:06Z","created_by":"Edward Langley","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\nround_half_away guards on (n * factor).is_finite(). Test pins the threshold (1e307*100 overflows, 1e306*100 does not) AND carries an anti-clamp assertion that half-away rounding still happens (2.5, 0 -\u003e \"3\", where a passthrough gives Rust's half-to-even \"2\") -- because 'return n unchanged' satisfies the overflow case and every other case too.\n\ngotcha 8 is why nothing else caught it: display rounding never reaches eval, so this is a wrong pixel with no wrong number behind it to trip a formula test.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-f86q","title":"Re-adding an existing category at the 12-category limit is refused with a false message","description":"Found by the core bug hunt, DEMONSTRATED.\n\nModel::add_category (model/types.rs:272) runs the `regular_count \u003e= MAX_CATEGORIES` check BEFORE the contains_key idempotence check. So with 12 regular categories, add_category(\"C0\") returns Err(\"Maximum of 12 categories reached\") although C0 already exists and nothing would be created.\n\n with C0..C11 added: wb.add_category(\"C0\") == Err(\"Maximum of 12 categories reached\")\n\nTwo things wrong. The message is false — the category exists. And per gotcha 21, effect::AddCategory sets abort_effects on refusal, so typing an EXISTING category name into the records-mode `+cat` column at 12 categories aborts the rest of the batch: the cursor does not advance and the editor does not reopen, which is the improvise-2zq failure mode arriving by a different route.\n\nNarrow — it needs 12 regular categories — but silent and misleading when reached.\n\n## Acceptance Criteria\n- One line, a move: hoist the contains_key early return above the limit check.\n- A test at exactly MAX_CATEGORIES re-adding an existing name, asserting Ok and asserting the batch is not aborted.\n","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-21T20:23:05Z","created_by":"Edward Langley","updated_at":"2026-09-21T21:58:31Z","closed_at":"2026-09-21T21:58:31Z","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\nOne-line hoist of the contains_key early return above the MAX_CATEGORIES check. Test asserts the returned id, that nothing was created, AND that a genuinely-new name is still refused, so it cannot pass by deleting the limit.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -788,10 +927,10 @@ {"_type":"issue","id":"improvise-r08u","title":"IS and WHERE differ on trailing word boundary in formula.pest","description":"improvise-4zl7 gave `IS` a trailing word-boundary lookahead: `is_expr` is compound-atomic so `Region ISLAND` no longer parses as `Is(Region, LAND)`. `^\"WHERE\"` in `where_clause` and `inline_where` still has the identical hole — `SUM(Rev WHEREAS x = y)` would read as a WHERE on a category `AS`. It is far less exposed than IS was (WHERE sits in one fixed position after an expression, where IS follows any identifier in expression position), which is why it was left alone rather than fixed in the same pass: it is pre-existing and changing it is a grammar change with its own round-trip risk.\n\n## Steps to Reproduce\n\n1. `parse_formula(\"X = SUM(Rev WHEREAS x = y)\", \"Cat\")`\n2. It parses, reading `AS` as the filter category, where it should be a syntax error.","acceptance_criteria":"Either `^\"WHERE\"` gets the same `!bare_char` lookahead `IS` has and a test asserting `WHEREAS` does not parse as a WHERE clause, or the asymmetry is declined in writing in context/code/formula-and-io.md with the reason. The grammar-walking generator proptests must still pass either way.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-20T19:16:46Z","created_by":"wave3-4zl7","updated_at":"2026-09-21T04:42:55Z","closed_at":"2026-09-21T04:42:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-1l9o","title":"furnished_client hand-builds a basis instead of taking the server's","description":"A nit from the adversarial review of PR #50, explicitly not worth a push at\nthe time.\n\nlockstep.rs's furnished_client builds its BasisCache by walking the client's\nown grid for Record cells and calling basis::published(0, 0, records) with\nevery version hard-coded to 0. That is a small re-implementation of\nproject.rs::basis_update's record selection, in a test whose entire purpose is\nto rule out re-implementations.\n\nIt is harmless today -- a fresh App really is at generation 0, layout 0, and\nevery record at version 0 -- which is exactly why it will stop being harmless\nsilently if any of those three stops being true.\n\nThe reviewer demonstrated the alternative while checking something else: seed\nthe ClientState from a real Sessions::connect snapshot and let the server's own\nProjection::Basis populate the cache. That removes the fabrication and makes\nthe fixture a client the server actually produced.\n\nNote the same fabrication exists in client_ready_to_edit, which predates this\nPR, so a fix should take both.","acceptance_criteria":"The lockstep fixtures get their basis from a real Sessions::connect\nsnapshot rather than constructing one. Both furnished_client and\nclient_ready_to_edit, since they share the fabrication. The tests that use\nthem must still pass unchanged -- if seeding from the server changes an\noutcome, that is a finding about the fixture and worth stopping for.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:58:19Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T12:39:37Z","closed_at":"2026-09-21T12:39:37Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-thpe","title":"help::compact discriminates a chord by parsing a string it formatted","description":"Finding 3 of the adversarial review of PR #50. Not live-broken and\ndeliberately not fixed in that PR.\n\nimprovise_protocol::help::compact drops chorded keys from a status-bar hint by\ntesting whether the formatted label starts with \"Ctrl-\" or \"Alt-\". The\nreviewer enumerated every format_key_label output and none begins with either,\nso there is no false positive today.\n\nThe defect is the shape, not the behaviour. keymap.rs::labels_for HAS the\nstructured fact -- it matches KeyPattern::Key(code, mods) and then throws mods\naway into a string -- and compact then reconstructs a worse version of it by\nparsing. A future modifier spelled any other way (Super-, Cmd-, a bare glyph)\nis silently not a chord, lands in the status bar, and nothing fails.\n\nThe fix is to carry the fact rather than re-derive it: either Hint keeps the\nKeyModifiers beside each label, or labels_for returns (KeyCode, KeyModifiers)\npairs and help.rs formats at the end. The second is tidier and is what\nwhich_key already does with key_label(code, *mods).\n\n## Steps to Reproduce\n1. Add a modifier to keymap.rs::key_label spelled with any prefix other than\n \"Ctrl-\" or \"Alt-\" -- \"Super-x\", say.\n2. Bind a command advertised by a mode's hint to that chord only.\n3. hint_line puts it in the status bar. Nothing fails.","acceptance_criteria":"compact stops parsing a string to learn something the keymap already\nknew: either Hint carries the KeyModifiers beside each label, or labels_for\nreturns (KeyCode, KeyModifiers) and help.rs formats last. Proved by adding a\nmodifier spelled differently and watching a test fail -- the point is that\ntoday it would not.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:45:36Z","created_by":"wave2-h-frontend","updated_at":"2026-09-21T17:09:50Z","closed_at":"2026-09-21T17:09:50Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","notes":"PR #76 (branch wave/command-effect-gaps) does HALF of this: App::resolve_view's fallback carries back the .expect(\"active_view always names an existing view\") message, guarded by a_default_that_names_nothing_says_which_invariant_broke in ui/app.rs. The SECOND half is untouched — whether a host-initiated DeleteView's session adoption belongs at the Sessions door or at Host::project is a decision about ui/project.rs and ui/host.rs, which were another agent's ground in this wave. Reported rather than guessed.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T21:27:25Z","labels":["area:active-view"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-h074","title":"Two edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass","description":"Found by the adversarial review of PR #48 (improvise-jytm). Two small things, both\nabout what happens at the edges of the fallback improvise-jytm introduced.\n\n**The panic message got worse.** `App::resolve_view` falls back to\n`workbook.active_view` for a name the document has not got, then indexes\n`workbook.views[default]`. `Workbook::active_view()` — which several of these call\nsites used before — carries `.expect(\"active_view always names an existing view\")`.\n`IndexMap`'s `Index` impl panics with no such message. So a `Workbook` whose\n`active_view` names nothing (which only a hand-built or deserialized-from-elsewhere\nworkbook can be, since `create_view` and `delete_view` both maintain it) now fails\nwith a bare index panic where it used to name the invariant that was broken. Cheap\nfix: keep the `.expect` string.\n\n**A host-initiated `DeleteView` bypasses `adopt_deleted_views`.** That function runs\nin `Sessions::apply_model`, which is the door a *client's* command comes through. A\nhost driving `App::apply_effects` directly — through `Host::app_mut()` — deletes a\nview without any session mirror being moved off it.\n\n**Read the unreachability argument carefully, because it is weaker than it looks.**\nA first version of this ticket said `app_mut()` had one caller, in\n`improvise-ws-server/src/main.rs`, at startup, before any client had connected. That\nwas wrong, and review caught it. The one caller is\n`crates/improvise-worker-server/src/worker.rs:64`, inside `persist()` — which runs\n**after every accepted model command, with a client connected**. The bypass is still\nunreachable, but only because `persist()` happens to pass `effect::Save`, which\ndeletes no view. \"The one existing caller passes a harmless effect\" is a much thinner\nguarantee than \"nobody is connected yet\", and it is the exact shape of premise this\nrepo has been burned by: gotcha 47 records that the lost update `CheckedBatch` later\nfixed had shipped under an unreachability argument too.\n\nThe second is the interesting one to decide rather than to patch: it is the general\nshape of \"session bookkeeping that only the session door performs\". `Host::project`\nis already where every outbound path funnels, so whether the adoption belongs there\ninstead is the design question.\n\n## Steps to Reproduce\n\nThe panic message:\n\n1. Build a `Workbook` whose `active_view` names a view not in `views` (only reachable\n by constructing the struct directly — the constructors maintain the invariant).\n2. Call `App::view()`. It panics from `IndexMap`'s `Index` impl with no message,\n where `Workbook::active_view()` would have said which invariant broke.\n\nThe bypass:\n\n1. A client connects and switches to \"Second\".\n2. The host calls `host.app_mut().apply_effects(vec![Box::new(DeleteView(\"Second\"))])`\n — the same door `improvise-worker-server`'s `persist()` uses for `effect::Save`\n after every accepted model command.\n3. The session's mirror still names \"Second\". Nothing moved it and nothing told the\n client, because `Sessions::adopt_deleted_views` runs only in `apply_model`.\n","acceptance_criteria":"App::resolve_view's fallback index carries the invariant message Workbook::active_view() had. And a decision is recorded on whether session adoption belongs at the Sessions door or at Host::project — with a test for a host-initiated DeleteView if the answer moves it.","notes":"PR #76 (branch wave/command-effect-gaps) does HALF of this: App::resolve_view's fallback carries back the .expect(\"active_view always names an existing view\") message, guarded by a_default_that_names_nothing_says_which_invariant_broke in ui/app.rs. The SECOND half is untouched — whether a host-initiated DeleteView's session adoption belongs at the Sessions door or at Host::project is a decision about ui/project.rs and ui/host.rs, which were another agent's ground in this wave. Reported rather than guessed.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:33:43Z","created_by":"wave2-e-active-view","updated_at":"2026-09-21T21:27:25Z","labels":["area:active-view"],"dependencies":[{"issue_id":"improvise-h074","depends_on_id":"improvise-pmt0","type":"parent-child","created_at":"2026-09-25T01:05:04Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-x4wv","title":"assemble.sh's stamped guard under-reports multiple broken substitutions, and its comment reads as though it does not","description":"Found by injection during PR #47 (the landing-page inversion), by the WU-P agent.\n\nweb/assemble.sh's four 'stamped' checks exist because one grep over four files succeeds when ANY one of them matches, so three failed substitutions could still produce a successful assembly under set -e (found by Copilot on PR #24). The comment says each stamped reference 'is verified on its own and names itself when it is the one missing'.\n\nInjection result: breaking ONE substitution (a wrong sedi pattern for worker.js) produces exactly the claimed behaviour —\n\n assemble: .../app/worker.js is missing the cache-bust stamp: worker-pkg/improvise_worker_server.js?v=inj\n\nwith the app.js guard ahead of it passing and printing its match. Correct and isolated.\n\nBut 'stamped' calls exit 1 on the FIRST failure, so breaking BOTH app.js and worker.js reports app.js alone. The phrase 'when it is the one missing' is accurate for a single failure and misleading for a multiple one: a reader takes it as 'each missing stamp names itself'.\n\nThis FAILS SAFE — the build stops and a real problem is reported — so it is P4, not a correctness bug. It is filed because the prose is what a future agent will act on, and because the original guard was itself the product of a review finding about a check that passed when it should not have.\n\nDeliberately NOT fixed in PR #47: pre-existing, out of that unit, and reshaping stamped would rewrite a guard whose current form came out of the PR #24 finding. The cheap fix is probably the comment, not the code.\n\n## Steps to Reproduce\n\n1. In web/assemble.sh, corrupt the sedi patterns for BOTH app.js and worker.js.\n2. Run web/assemble.sh into a scratch dir.\n3. Only the app.js failure is reported; the worker.js one is never reached.","acceptance_criteria":"Either stamped reports every missing stamp before exiting, or its comment says plainly that it reports the FIRST failure only. Demonstrated by breaking two substitutions and seeing both named, or by the comment matching the single-report behaviour.","status":"closed","priority":4,"issue_type":"bug","assignee":"claude-agent-scripts","owner":"el-github@elangley.org","created_at":"2026-09-20T18:14:57Z","created_by":"orchestrator","updated_at":"2026-09-20T23:35:12Z","started_at":"2026-09-20T23:22:06Z","closed_at":"2026-09-20T23:35:12Z","close_reason":"fixed in PR #69, each proved by injection","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-gcvb","title":"The browser cannot commit a records-mode creating column, though it is now nameable","description":"`keys.rs::commit` returns early for EVERY records-mode commit:\n\n if state.view.mode.is_records() { return None; }\n\nThe comment gives the real reason — \"a category column renames every measure\ncell in the row, which needs `record_col`\" (gotcha 1) — and that reason is\nsound for the coordinate columns and for the measure columns' advance, which\n`CommitAndAdvance` handles with `AddRecordRow` at the bottom-right and a\n`RebuildLayout` when `+cat` inserts a column.\n\nBut the guard is broader than its reason. improvise-poia made the two CREATING\ncolumns nameable: a cell at the cursor now projects `CellId::Create(Measure)`\nor `CellId::Create(Category)`, and committing one is a single existing\n`ModelCommand` over the typed NAME — `AddItem { category: \"_Measure\", item }`\nor `AddCategory(name)`. Neither reads `record_col`, neither touches the record\nunder the cursor, and neither writes a cell. So this is the one records-mode\ncommit a thin client could already compose, and the early return is what stops\nit.\n\nWhat has to be answered before it lands, and why it is not a one-liner:\n\n- The ADVANCE differs. `CommitAndAdvance` returns straight after a `+cat`\n commit with a `RebuildLayout` and no cursor move, because an inserted column\n shifts every index to its right and the advance was computed from the\n pre-commit layout (gotcha 23). A client that advanced anyway would land one\n column off, silently.\n- `AddCategory` can be REFUSED (the 12-category limit), and it sets\n `abort_effects` (gotcha 21). A client composing `[AddCategory, ChangeMode]`\n is composing a batch whose first step may abort — which is exactly the hole\n clause 4 of gotcha 47 leaves open.\n\nBoth are arguments for doing this with a lockstep test against\n`CommitAndAdvance`, not by reading the resolver and hoping.\n","acceptance_criteria":"keys.rs::commit composes AddItem/AddCategory when the cell at the cursor is CellId::Create, instead of returning None for the whole of records mode — and a tests/lockstep.rs case proves the client and CommitAndAdvance land the document AND the cursor in the same place, including the +cat case where an inserted column means the TUI does not advance at all. The coordinate and measure columns stay the host's; the early return narrows rather than disappears.","status":"closed","priority":4,"issue_type":"task","assignee":"claude-wasm-keys-agent","owner":"el-github@elangley.org","created_at":"2026-09-20T18:13:31Z","created_by":"wave2-b-projection","updated_at":"2026-09-21T00:24:03Z","started_at":"2026-09-20T23:21:16Z","closed_at":"2026-09-21T00:24:03Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-trd3","title":"Help pages clamp at both ends; decide whether they should wrap","description":"Split out of improvise-e24v, where the divergence was found and closed the\nconservative way.\n\neffect::HelpPageNext clamps at HELP_PAGE_COUNT - 1 and HelpPagePrev saturates\nat 0. reduce_view's Command::HelpPage arm wrapped modulo HELP_PAGE_COUNT. Two\nderivations of one fact, disagreeing, and invisible because no client had ever\nsent Command::HelpPage: the overlay's h/l were upstream keys until e24v made\nthem a client's.\n\ne24v changed the WIRE to match the EFFECT, because the effect is what ships in\nthe TUI and changing that is a product decision nobody asked for. But wrapping\nmay well be the nicer behaviour on a five-page overlay, and if it is wanted it\nhas to change in the effect *and* the reducer together.\n\n## Steps to Reproduce\n1. Open the TUI and press ? for help.\n2. Press l five times.\n3. The overlay stops on page 5 of 5. Before e24v, a browser client folding the\n same binding would have been back on page 1.","acceptance_criteria":"A ruling: either both ends clamp (today's behaviour, in which case close\nthis and the existing tests are the record), or both wrap. If wrapping wins,\neffect::HelpPageNext/HelpPagePrev and reduce_view's HelpPage arm change in one\ncommit and walking_off_the_end_of_the_help_pages_agrees_with_the_host is\nupdated with them — the point of the ticket is that these two cannot be\nallowed to disagree again.","status":"open","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T18:04:16Z","created_by":"wave2-h-frontend","updated_at":"2026-09-20T18:04:16Z","labels":["area:shared-frontend"],"dependencies":[{"issue_id":"improvise-trd3","depends_on_id":"improvise-gtn7","type":"parent-child","created_at":"2026-09-25T01:05:34Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bgtf","title":"The landing page is served by the harness and asserted by nothing","description":"`docs/index.html` (improvise-e61) is copied into the deployed site at\n`/landing/` by `web/assemble.sh`, so the browser harness already serves it —\n`harness/serve` is the same assembled tree — and nothing asserts anything about\nit. That is the shape gotcha 48 names: argued and read, never executed.\n\nWHAT A CHECK MUST DO, and the reason this bead exists rather than a one-liner:\ncounting the four `.ap-player` elements PROVES NOTHING. `autoPlay` is false, so\nasciinema-player does not fetch a cast until the viewer presses play. A page\nwhose every cast path 404s constructs four players, throws no page error before\nthe click, and looks identical to a working one. This was verified by injecting\nexactly that fault (`./casts/` -\u003e `./cats/`) while building the page: the\nconstruction-only check passed on the broken page; pressing play and counting\n`.ap-line` returned 0 and surfaced four 404s.\n\nSo the spec must press play on each of the four and assert rendered lines, and\nits own fault-injection must be recorded in `context/test-evidence/`.\n\nSCOPE: one spec beside `harness/tests/shell.spec.js`, Chromium only (nothing\nhere is engine-dependent), navigating to `/landing/index.html`.\n\n## Steps to Reproduce\n\n1. `./web/build.sh \u0026\u0026 ./web/assemble.sh site dev`\n2. `python3 -m http.server 8099 --directory site`\n3. Open `http://127.0.0.1:8099/landing/index.html` — it renders, and no test\n in the repository would have noticed if it had not.\n4. Break a cast path in `docs/index.html`, repeat, and observe that the page\n still looks structurally correct until a play button is pressed.","acceptance_criteria":"A Playwright spec beside harness/tests/shell.spec.js loads /landing/index.html, presses play on each of the four casts, and asserts each renders a non-zero number of terminal lines with no failed request and no page error. The spec is proved able to fail by breaking one cast path and watching it fail, and that injection is recorded in context/test-evidence/.","notes":"PR #72. harness/tests/landing.spec.js presses play on all four casts and asserts rendered lines plus four distinct 200s; ./casts/ -\u003e ./cats/ fails it. Note the bead's path is stale: the landing page is at the site ROOT (/index.html) since improvise-kh8, not /landing/.","status":"closed","priority":4,"issue_type":"task","assignee":"wave4-harness","owner":"el-github@elangley.org","created_at":"2026-09-20T18:01:16Z","created_by":"wave2-p-milestones","updated_at":"2026-09-21T00:24:05Z","started_at":"2026-09-20T23:21:57Z","closed_at":"2026-09-21T00:24:05Z","close_reason":"Fixed and merged: rmvx/fdt6/gcvb in PR #71, the harness four in PR #72. Both adversarially reviewed, with each finding's injection re-run against the fix. Closed by the orchestrator with --force because the claiming agent has stood down; the claim guard refusing an unforced close is the mechanism working, not a problem.","labels":["area:release"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-5jm5","title":"Model::evaluate_aggregated takes a list it only tests for emptiness","description":"Found by CC-core while capping comments (2026-09-20). Not filed as a bug because nothing misbehaves today; filed because the signature promises something the body does not do.\n\n`Model::evaluate_aggregated` takes `none_cats: \u0026[String]` and only ever tests `none_cats.is_empty()`. The aggregation itself is over `key.0` via `matching_values`. So the parameter is a BOOLEAN WEARING A LIST'S TYPE.\n\n## Steps to Reproduce\n\n1. Read `Model::evaluate_aggregated`'s signature: it takes a slice of category names.\n2. Read the body: the slice is consulted once, for emptiness. No element is ever examined.\n3. Pass a list with different contents but the same emptiness — the result is identical.\n\n## Why it is worth an hour\n\nThe name promises the function filters by that list. It does not. A caller reasoning about which categories are on `Axis::None` will believe this function honours them, and the next person to add a case will thread a carefully-built list into a parameter that cannot use it. That is the same shape as `prune_axis_items` taking a flag vector before it took a predicate: a parameter whose type admits states the body cannot distinguish.\n\n## The fix is a decision, not a rename\n\nEither the emptiness test is the real dispatch — in which case take a `bool` (or better, an enum saying what the two states MEAN) and let the caller compute it — or the function was meant to filter and does not, which is a behaviour question and needs a test first. Establish which before touching it; `git log -S` on the parameter will say whether it ever filtered.\n","acceptance_criteria":"evaluate_aggregated's parameter admits only the states the body can distinguish — a bool or a named enum if the emptiness test is the real dispatch, or a filter that actually filters with a test proving it. Establish which it was MEANT to be first: git log -S on the parameter says whether it ever examined an element.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-20T16:53:36Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:42:55Z","closed_at":"2026-09-21T04:42:55Z","close_reason":"Closed","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-bidq","title":"Two answers to 'is this category virtual', four lines apart in view/types.rs","description":"Found by the post-wave code consistency review. PRE-EXISTING, carried across unchanged by PR #27's 8173b87.\n\ncrates/improvise-core/src/view/types.rs:112 and :118 test whether a category is virtual with `cat_name.starts_with('_')`. Four lines away, `is_records_layout()` (:67-74) uses `CategoryKind::of_name(..)`, and `CategoryKind` is imported at :6.\n\nTwo answers to \"is this category virtual\", in one file, four lines apart.\n\n## Why it is reachable rather than theoretical\n\n`Model::add_category` does not validate names. So a user category literally named `_Foo` is:\n * `CategoryKind::Regular` to records_columns, cell_id and is_empty_model;\n * \"virtual\" to these two lines — which would seat it on Axis::None and it would never appear in a pivot.\n\ndesign-principles §2 is explicit that business rules go through the enum rather than a name prefix, and names this exact class: \"enforced by matching on the enum, not by checking name prefixes\".\n\n## Note\n\nThe cheap fix is to use CategoryKind at both sites. The better question, worth one line of thought first: should `add_category` refuse a leading underscore outright? That would make the divergence unrepresentable rather than merely consistent — but it is a user-visible restriction and belongs in the commit message either way.\n\n## Steps to Reproduce\n\n1. `Model::add_category(\"_Foo\")` — the name is not validated.\n2. `CategoryKind::of_name(\"_Foo\")` makes it `Regular`, so `records_columns`, `cell_id` and `is_empty_model` treat it as an ordinary user category.\n3. `view/types.rs:112` and `:118` test `cat_name.starts_with('_')`, so *they* treat it as virtual — and seat it on `Axis::None`.\n4. The category therefore never appears in a pivot, while every other consumer believes it is a normal one.\n\nFour lines away, `is_records_layout()` (`:67-74`) asks `CategoryKind`, and `CategoryKind` is imported at `:6`.\n","acceptance_criteria":"Both sites use CategoryKind, so there is one answer. Decide explicitly whether Model::add_category should refuse a leading underscore — if yes, the divergence becomes unrepresentable and the restriction is stated in the commit; if no, say why a user category named _Foo is allowed and now behaves consistently.","notes":"SHARPENED by CC-core (comment wave, 2026-09-20), and the trap is subtler than this ticket stated.\n\nIt is not two sites four lines apart. View::on_category_added's early return is:\n\n cat_name.starts_with('_') || self.is_records_layout()\n\nso THE SAME EXPRESSION mixes both derivations, ONE OPERATOR APART -- the left operand tests a name prefix and the right operand asks CategoryKind. A reader checking this line sees the enum being used and has no reason to look at the operand beside it.\n\nConfirmed still present at the comment wave's base commit; deliberately untouched (a behaviour change is out of that wave's scope). The comment residue there no longer asserts either spelling, and context/code/core.md section 7 records it as an open defect.","status":"closed","priority":4,"issue_type":"bug","assignee":"agent-render-frame","owner":"el-github@elangley.org","created_at":"2026-09-20T07:50:12Z","created_by":"Edward Langley","updated_at":"2026-09-21T03:46:31Z","started_at":"2026-09-21T03:39:56Z","closed_at":"2026-09-21T03:46:31Z","close_reason":"Fixed on PR #77 (fix/render-frame-wave, head 8f3a976). Each guard proved by injecting the fault it names; GROUPED_FRAME and harness/tests/grouped.spec.js re-pinned deliberately.","labels":["area:core-io"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -803,8 +942,8 @@ {"_type":"issue","id":"improvise-g6s1","title":"WizardStep::Preview has no characterization fixture","description":"The one import-wizard step improvise-764 could not pin. It needs a document that parses to ZERO RECORDS without needing path selection, and neither existing fixture reaches it.\n\nSmall, and worth doing while the WizardCache shape is fresh: every other step is pinned at buffer level, so Preview is the single arm where a change to wizard_cache could alter what is drawn with nothing failing.","status":"closed","priority":4,"issue_type":"task","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:36Z","created_by":"Edward Langley","updated_at":"2026-09-20T06:28:59Z","started_at":"2026-09-20T05:32:39Z","closed_at":"2026-09-20T06:28:59Z","close_reason":"Closed by PR #22 (WU-L). WizardStep::Preview pinned byte for byte.\n\nPreview is only ever an ENTRY step -- no path selection needed, zero records parsed -- so nothing advances into it and neither existing fixture starts there. An empty array is the smallest document that reaches it. The step is asserted as a PRECONDITION so the fixture cannot quietly stop reaching Preview and keep passing, which is the failure mode a characterization test of an unreachable step would otherwise have.\n\nProved discriminating by perturbing project::wizard_cache (uppercasing preview_summary) and watching it fail.\n\nIt also records a wart AS-IS rather than quietly fixing it: with no records to take keys from, the summary ends \"Sample keys:\" with nothing after the colon. Pinning the wart is the point of a characterization test; fixing it silently inside one is how the evidence for a later change gets destroyed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k69y","title":"[bug] draw_status reads the document's active view, not the client's","description":"src/draw.rs::draw_status uses app.model_state.workbook.active_view; ViewState::active_view is the CLIENT copy (gotcha 29).\n\nHARMLESS TODAY -- they are in lockstep and the_two_active_view_names_never_diverge guards it. WRONG THE DAY improvise-jytm LANDS, which is exactly the ticket that makes them diverge on purpose (the document keeps the DEFAULT, the client is authoritative). On that day the TUI badge names the wrong view.\n\nOne line. Left unfixed because it is a behaviour change and belongs with jytm rather than ahead of it. Note the wasm client already reads the correct one (self.view.active_view), so the TUI is the outlier.\n\n## Steps to Reproduce\n\nNot reproducible today, and that is the point of the ticket. `src/draw.rs::draw_status` reads `app.model_state.workbook.active_view` where `ViewState::active_view` is the client's copy (gotcha 29). The two are held in lockstep and `the_two_active_view_names_never_diverge` guards it, so the TUI badge is correct **by accident**.\n\nIt becomes reproducible the day improvise-jytm lands, which is the ticket that makes them diverge on purpose — the document keeps the default, the client is authoritative. On that day: two clients, one switches view, and the TUI badge names the wrong one.\n\nThe wasm client already reads the correct one (`self.view.active_view`), so the TUI is the outlier.\n","acceptance_criteria":"src/draw.rs::draw_status reads ViewState::active_view, not workbook.active_view. Lands WITH improvise-jytm and not before — it is a behaviour change only once the two copies are allowed to diverge, and until then it is a no-op that would look like an unmotivated edit. The wasm client already reads the correct one, so the TUI is the outlier.","status":"closed","priority":4,"issue_type":"bug","assignee":"wave2-e-active-view","owner":"el-github@elangley.org","created_at":"2026-09-20T00:58:35Z","created_by":"Edward Langley","updated_at":"2026-09-21T04:43:47Z","started_at":"2026-09-20T17:53:50Z","closed_at":"2026-09-21T04:43:47Z","labels":["area:active-view"],"dependencies":[{"issue_id":"improvise-k69y","depends_on_id":"improvise-jytm","type":"blocks","created_at":"2026-09-20T16:20:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-poia","title":"The records-mode + column has no projected identity","description":"From the ADR 002 spine landing (2026-09-19). In records mode the trailing + (NewMeasure) column creates a new measure on the row (gotcha 1). It carries no CellId, so creating a measure over the wire has no address.\n\nThat is correct for now rather than an oversight -- a new measure is not a write to an existing cell, so it is not a SetCell and should not borrow one identity. But it means a browser client cannot do something the TUI can.\n\nDecide what it should be: a distinct command carrying the record surrogate plus the new measure name (derived from the effect that does it today, per doctrine(derive_the_enum_from_the_effects)), or a client-side flow that adds the measure and then writes the cell as two commands. The second is probably right -- it composes from operations that already exist -- but check whether the TUI treats it as one atomic action first, because splitting an atomic action across two commands is how a half-applied state appears.","acceptance_criteria":"Creating a measure from the records-mode + column has an address on the wire. Prefer the two-command client-side flow (add the measure, then write the cell) IF the TUI does not treat it as one atomic action — check that first, because splitting an atomic action across two commands is how a half-applied state appears, and clause 4 (improvise-yqpd) does not exist yet to catch it. If the TUI IS atomic, the answer is a distinct command carrying the record surrogate plus the new measure name, derived from the effect that does it today. Note improvise-2zq added a SECOND creating column (+cat) with the same question.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-b-projection","owner":"el-github@elangley.org","created_at":"2026-09-20T00:18:35Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:14:05Z","started_at":"2026-09-20T18:11:15Z","closed_at":"2026-09-20T18:14:05Z","close_reason":"Closed","labels":["area:projection"],"dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-mjy7","title":"Export a model as SQLite for interop (one-way, native-only)","description":"DEFERRED 2026-09-19 by user ruling during the whole-backlog campaign, together with improvise-qddo and improvise-rucf, and rewritten to say where it sits relative to ADR 003.\n\nTHE CLAIM IS STILL GOOD AND IS UNAFFECTED BY THE ADR. This is EXPORT for interop -- a user opening their model in sqlite3 or DB Browser to run their own queries. One-way, no round-trip obligation, and therefore none of the grammar-duplication risk that killed improvise-9ix: a second FORMAT must track every future change to improv.pest forever, a second VIEW need not, because nothing loads it back. It sits naturally beside export_csv/export_csv_to in crates/improvise-io/src/persistence/export.rs.\n\nWHY IT IS DEFERRED ANYWAY: it is the one bead in the SQLite cluster with no dependency on ADR 003, but it carries the same cost as the cluster's real work -- a rusqlite dependency and a wasm-gate verification -- for a capability nobody has asked for concretely. The user's ruling was to defer the cluster as a unit. Un-defer on a concrete interop request, or alongside improvise-rucf.2 (the SQLite source), where the dependency is being added anyway and the incremental cost of an exporter is small.\n\nNOTE THE DIRECTION IS OPPOSITE TO ADR 003'S. ADR 003 is about READING someone else's database as a live source. This is about WRITING ours out as a convenience. They share only the rusqlite dependency and the gating constraint; do not let one justify the other.\n\nTHE ONE REAL COST: cargo check --target wasm32-unknown-unknown fails in the libsqlite3-sys C build under this repo's nix cc-wrapper, so this must be NATIVE-ONLY AND FEATURE-GATED with the wasm CI job passing untouched. VERIFY that rather than assume it -- the improvise-9ix agent was careful to note the failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating is about protecting the browser bundle, not about an impossibility.\n\nSCOPE WHEN IT LANDS: choose a schema that is USEFUL TO QUERY rather than one that mirrors internal storage -- a reader wants cells joined to their coordinates, not an interned key blob. Do not add a reader; that is improvise-rucf's job and it has a different answer.\n","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T00:08:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:48Z","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-zavn","title":"Drill previews on hover: a bounded record keyset per aggregate","description":"DEFERRED by the user (2026-09-19) -- a good idea, recorded so the shape is not lost and so nobody builds the UNBOUNDED version by accident.\n\nTHE IDEA: show the underlying rows when the user hovers an aggregate cell, without a round trip.\n\nTHE TRAP THIS AVOIDS. Projecting the full keyset for every aggregate is untenable and the cost runs OPPOSITE to the value. A stored cell has fixed coordinates so it lands in exactly one displayed cell -- the keysets PARTITION rather than multiply -- but that still makes the payload scale with N (the data) while everything else in a projection scales with the VIEWPORT. Aggregation is precisely what makes a large document fit a screen, so shipping the keysets undoes that compression. Worked numbers for a 1000-cell viewport at u128: ~16KB at one record per cell (where the cell would be settable anyway and no keyset is needed), ~160KB at ten, and ~16MB over a million-record document -- to render a thousand cells. Per client, re-diffed as data changes. And the cheap cases are the ones where drilling barely matters.\n\nTHE SHAPE THAT WORKS: ship at most k record hashes per aggregate PLUS A COUNT, so the payload is viewport-proportional again, and fetch the full set on demand when the user actually drills. k is a product decision -- enough rows to make a hover useful, e.g. 3 to 5.\n\nPREREQUISITE: ADR 002 (context/adr/002-naming-a-cell.md). An aggregate already carries CellId::Aggregate(AggregateHash), so drill-on-demand works without this ticket; this is purely the latency optimisation for hover. Do not conflate the two -- drilling must stay correct without any preview.\n\nNote the preview hashes are RECORD hashes, so they resolve through the same DataStore index and obey the same staleness rules: a preview row whose record has since gone simply fails to resolve.","notes":"\n--- BLOOM FILTER IDEA (user, 2026-09-19): right tolerance, different question ---\nProposal was a Bloom filter instead of an explicit keyset, on the grounds that\nfalse positives are fine given a deterministic aggregate id to ask\nauthoritatively. The tolerance argument is right; the direction needs\ncorrecting.\n\nA BLOOM FILTER CANNOT ENUMERATE. A hover preview needs ROWS -- identities and\nvalues -- while a filter answers \"is X probably in this set?\" for an X you\nalready hold. The client holds aggregates, not the underlying records, so it has\nno candidates to test. So this does not serve previews directly.\n\nWHERE IT FITS INSTEAD, and it fits well: the REVERSE question, \"which visible\naggregates does record R feed?\" A client staging an edit to R tests R against\neach visible cell filter and marks the hits pending -- greyed, spinner -- with\nno round trip. A false positive marks one extra cell pending; it never misses\none. That asymmetry is exactly gotcha 35 rule: over-reporting costs work,\nunder-reporting is the bug, which is why Dirty::of is a deliberate\nover-approximation. Same shape.\n\nSIZE CORRECTION THAT MAKES IT CHEAP: a conventionally-sized filter still scales\nwith N -- roughly 9.6 bits per element at 1% FP, so ~1.2KB for a cell\naggregating 1000 records, and the payload again scales with the DATA. FIX THE\nWIDTH instead: a fixed 64- or 128-bit filter per cell is viewport-proportional\nby construction. Small aggregates are precise; large ones SATURATE and answer\n\"maybe\" to everything, degrading to mark-everything -- the safe direction, and\nthe same failure the design already tolerates.\n\nThe deterministic aggregate id is what makes the sloppiness safe: the\nauthoritative answer is one Drill(AggregateHash) away, so the filter is never\nload-bearing for correctness. Same structure as ADR 001 advisory reservations,\nwhere correctness never rests on the advisory layer.\n\nSO THIS TICKET NOW HOLDS TWO SEPARABLE IDEAS -- split them when picked up:\n (a) hover previews: needs a BOUNDED EXPLICIT keyset (k hashes + a count),\n because a preview needs rows;\n (b) optimistic edit feedback: a FIXED-WIDTH Bloom filter per aggregate,\n answering which visible cells a pending record edit affects.\n(b) belongs with ADR 001 layer 2 rather than here.\nDEFERRAL RE-AFFIRMED 2026-09-19 during the whole-backlog campaign, status set to deferred so it stops appearing as ready work.\n\nThe reason it is recorded rather than closed is unchanged and is the important part: drilling is already correct without it (CellId::Aggregate resolves on demand), and the trap this bead exists to prevent is someone building the UNBOUNDED version -- projecting every aggregate's full keyset, whose payload scales with the data while every other projection scales with the viewport. The shape that works is at most k record hashes plus a count.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:03Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-mjy7","title":"Export a model as SQLite for interop (one-way, native-only)","description":"DEFERRED 2026-09-19 by user ruling during the whole-backlog campaign, together with improvise-qddo and improvise-rucf, and rewritten to say where it sits relative to ADR 003.\n\nTHE CLAIM IS STILL GOOD AND IS UNAFFECTED BY THE ADR. This is EXPORT for interop -- a user opening their model in sqlite3 or DB Browser to run their own queries. One-way, no round-trip obligation, and therefore none of the grammar-duplication risk that killed improvise-9ix: a second FORMAT must track every future change to improv.pest forever, a second VIEW need not, because nothing loads it back. It sits naturally beside export_csv/export_csv_to in crates/improvise-io/src/persistence/export.rs.\n\nWHY IT IS DEFERRED ANYWAY: it is the one bead in the SQLite cluster with no dependency on ADR 003, but it carries the same cost as the cluster's real work -- a rusqlite dependency and a wasm-gate verification -- for a capability nobody has asked for concretely. The user's ruling was to defer the cluster as a unit. Un-defer on a concrete interop request, or alongside improvise-rucf.2 (the SQLite source), where the dependency is being added anyway and the incremental cost of an exporter is small.\n\nNOTE THE DIRECTION IS OPPOSITE TO ADR 003'S. ADR 003 is about READING someone else's database as a live source. This is about WRITING ours out as a convenience. They share only the rusqlite dependency and the gating constraint; do not let one justify the other.\n\nTHE ONE REAL COST: cargo check --target wasm32-unknown-unknown fails in the libsqlite3-sys C build under this repo's nix cc-wrapper, so this must be NATIVE-ONLY AND FEATURE-GATED with the wasm CI job passing untouched. VERIFY that rather than assume it -- the improvise-9ix agent was careful to note the failure is the nix wrapper injecting -fzero-call-used-regs rather than proof that bundled SQLite cannot target wasm, so the gating is about protecting the browser bundle, not about an impossibility.\n\nSCOPE WHEN IT LANDS: choose a schema that is USEFUL TO QUERY rather than one that mirrors internal storage -- a reader wants cells joined to their coordinates, not an interned key blob. Do not add a reader; that is improvise-rucf's job and it has a different answer.\n","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-20T00:08:37Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:48Z","dependencies":[{"issue_id":"improvise-mjy7","depends_on_id":"improvise-rucf","type":"parent-child","created_at":"2026-09-25T01:04:37Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-zavn","title":"Drill previews on hover: a bounded record keyset per aggregate","description":"DEFERRED by the user (2026-09-19) -- a good idea, recorded so the shape is not lost and so nobody builds the UNBOUNDED version by accident.\n\nTHE IDEA: show the underlying rows when the user hovers an aggregate cell, without a round trip.\n\nTHE TRAP THIS AVOIDS. Projecting the full keyset for every aggregate is untenable and the cost runs OPPOSITE to the value. A stored cell has fixed coordinates so it lands in exactly one displayed cell -- the keysets PARTITION rather than multiply -- but that still makes the payload scale with N (the data) while everything else in a projection scales with the VIEWPORT. Aggregation is precisely what makes a large document fit a screen, so shipping the keysets undoes that compression. Worked numbers for a 1000-cell viewport at u128: ~16KB at one record per cell (where the cell would be settable anyway and no keyset is needed), ~160KB at ten, and ~16MB over a million-record document -- to render a thousand cells. Per client, re-diffed as data changes. And the cheap cases are the ones where drilling barely matters.\n\nTHE SHAPE THAT WORKS: ship at most k record hashes per aggregate PLUS A COUNT, so the payload is viewport-proportional again, and fetch the full set on demand when the user actually drills. k is a product decision -- enough rows to make a hover useful, e.g. 3 to 5.\n\nPREREQUISITE: ADR 002 (context/adr/002-naming-a-cell.md). An aggregate already carries CellId::Aggregate(AggregateHash), so drill-on-demand works without this ticket; this is purely the latency optimisation for hover. Do not conflate the two -- drilling must stay correct without any preview.\n\nNote the preview hashes are RECORD hashes, so they resolve through the same DataStore index and obey the same staleness rules: a preview row whose record has since gone simply fails to resolve.","notes":"\n--- BLOOM FILTER IDEA (user, 2026-09-19): right tolerance, different question ---\nProposal was a Bloom filter instead of an explicit keyset, on the grounds that\nfalse positives are fine given a deterministic aggregate id to ask\nauthoritatively. The tolerance argument is right; the direction needs\ncorrecting.\n\nA BLOOM FILTER CANNOT ENUMERATE. A hover preview needs ROWS -- identities and\nvalues -- while a filter answers \"is X probably in this set?\" for an X you\nalready hold. The client holds aggregates, not the underlying records, so it has\nno candidates to test. So this does not serve previews directly.\n\nWHERE IT FITS INSTEAD, and it fits well: the REVERSE question, \"which visible\naggregates does record R feed?\" A client staging an edit to R tests R against\neach visible cell filter and marks the hits pending -- greyed, spinner -- with\nno round trip. A false positive marks one extra cell pending; it never misses\none. That asymmetry is exactly gotcha 35 rule: over-reporting costs work,\nunder-reporting is the bug, which is why Dirty::of is a deliberate\nover-approximation. Same shape.\n\nSIZE CORRECTION THAT MAKES IT CHEAP: a conventionally-sized filter still scales\nwith N -- roughly 9.6 bits per element at 1% FP, so ~1.2KB for a cell\naggregating 1000 records, and the payload again scales with the DATA. FIX THE\nWIDTH instead: a fixed 64- or 128-bit filter per cell is viewport-proportional\nby construction. Small aggregates are precise; large ones SATURATE and answer\n\"maybe\" to everything, degrading to mark-everything -- the safe direction, and\nthe same failure the design already tolerates.\n\nThe deterministic aggregate id is what makes the sloppiness safe: the\nauthoritative answer is one Drill(AggregateHash) away, so the filter is never\nload-bearing for correctness. Same structure as ADR 001 advisory reservations,\nwhere correctness never rests on the advisory layer.\n\nSO THIS TICKET NOW HOLDS TWO SEPARABLE IDEAS -- split them when picked up:\n (a) hover previews: needs a BOUNDED EXPLICIT keyset (k hashes + a count),\n because a preview needs rows;\n (b) optimistic edit feedback: a FIXED-WIDTH Bloom filter per aggregate,\n answering which visible cells a pending record edit affects.\n(b) belongs with ADR 001 layer 2 rather than here.\nDEFERRAL RE-AFFIRMED 2026-09-19 during the whole-backlog campaign, status set to deferred so it stops appearing as ready work.\n\nThe reason it is recorded rather than closed is unchanged and is the important part: drilling is already correct without it (CellId::Aggregate resolves on demand), and the trap this bead exists to prevent is someone building the UNBOUNDED version -- projecting every aggregate's full keyset, whose payload scales with the data while every other projection scales with the viewport. The shape that works is at most k record hashes plus a count.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-09-19T23:59:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-zavn","depends_on_id":"improvise-n28d","type":"parent-child","created_at":"2026-09-25T01:05:25Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ut7p","title":"improvise-gp fit_from_gen0 is a third copy of the GA loop","description":"Noted during improvise-0ai (2026-09-19), deliberately not changed. The generation loop now lives in gp_tree::fit::fit. improvise-gp test helper fit_from_gen0 is still its own loop, kept because it also returns the GENERATION-0 best, which the library fit does not expose -- and that is exactly what breeding_improves_on_generation_zero needs in order to prove the search moved rather than merely differed. So the duplication is load-bearing today. Two honest options: expose the generation-0 champion from gp_tree::fit (a fit result carrying the starting cost, or fit_with_progress) and rewrite the helper onto it, or leave it and document why. Prefer the first -- did the search improve on where it started is a question any caller of a GA wants answerable, not only a test.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-09-19T21:23:05Z","created_by":"Edward Langley","updated_at":"2026-09-19T23:40:24Z","closed_at":"2026-09-19T23:40:24Z","close_reason":"Took the preferred option. gp_tree::fit gains Fit { tree, cost, start, start_cost } and fit_detailed; fit is now fit_detailed(..).tree. start is the best member of GENERATION 0 of the restart that produced tree -- the stronger claim, and the one improvise-gp needed. improvise-gp fit_from_gen0 is gone, replaced by seeded_fit over fit_detailed, so the GA loop has one home.\n\nLoop shape preserved deliberately: generation i is still what is scored at the top of iteration i, the sort is still stable so member 0 wins its own ties and generation 0 champion equals the seed ancestor whenever nothing beat it -- that is what makes the restructure behaviour-preserving rather than merely plausible. breed runs the same number of times with the same arguments; the extra scoring pass draws nothing from the RNG.\n\nREPRODUCIBILITY EVIDENCE RE-ESTABLISHED, not assumed: all six known-answer vectors pass unchanged and the nine-scenario fit_csv harness is byte-identical including model.json, so every recorded seed still names the same tree. Two faults injected, both fired.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-0vh1","title":"number_text emits grammar-rejected 'inf' and panics on NaN","description":"From the wave-1 gp-tree review (2026-09-19). Both are guarded by documented preconditions, hence P4.\n\nimprovise-gp/src/lib.rs:124. number_text(f32::INFINITY): at p=0 it yields \"inf\", and \"inf\".parse::\u003cf64\u003e() succeeds, so the round-trip loop ACCEPTS it and emits 'inf' -- which the formula grammar rejects (number has no such form). fitted_formula then returns Err, which is fine, but a bare formula_text caller gets silently unparseable text. number_text(f32::NAN) never matches (NaN != NaN) and falls through to unreachable!(), i.e. panics.\n\nAlso a doc imprecision worth correcting while there (lib.rs:22-25 and 114-123): the comment says it emits 'the exact decimal value of the f32'. It emits the shortest decimal that ROUND-TRIPS to the same f64 -- which is exactly what the routing argument needs, and is a weaker and more accurate claim. Verified: over 20,000 random f32 bit patterns plus the extremes, every value found a width inside 0..=149, worst observed width 60, none emitted e/E/inf/NaN, all parsed and round-tripped.","notes":"\n--- FIXED, AWAITING MERGE (2026-09-19) ---\nThe fix is on branch wave-1-review-fixes and is in PR #9\n(https://github.com/fiddlerwoaroof/improvise/pull/9), which is open pending\nCopilot review. Moved to in_progress rather than left open so nobody picks\nthis up again -- see the agent-issue-drift-pattern memory. Close it when #9\nmerges, not before.","status":"closed","priority":4,"issue_type":"bug","owner":"el-github@elangley.org","created_at":"2026-09-19T20:43:13Z","created_by":"Edward Langley","updated_at":"2026-09-19T21:50:38Z","started_at":"2026-09-19T21:15:05Z","closed_at":"2026-09-19T21:50:38Z","close_reason":"Merged to main in PR #9 as e1acac691d70992b5dfcce9f11ce2c2b50fa1c29 (2026-09-19). Verified a real merge commit with both parents, not a squash, so all 18 branch commits are reachable on main per AGENTS.md's preference for merges. Copilot's second review on that PR: approval recommended, findings none. All eight non-skipped CI checks green, including claude-review. Synced to origin and gitea.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-k099","title":"AppMode::hint_text/label put terminal UI strings on a serde wire enum","description":"From the wave-1 review (2026-09-19). Pre-existing, introduced by improvise-2hi, not by this wave -- filed so it is tracked rather than rediscovered.\n\ngotcha 26's principle is that wire tokens are not UI labels. But AppMode::hint_text and AppMode::label are English, terminal-specific, keybinding-naming UI strings hanging off an enum that is serialized into the client Snapshot. A non-terminal or localized client cannot use them and has to map AppMode itself anyway.\n\nThe established fix shape in this repo is the AxisColor/MinibufColor one: keep a semantic value in the portable crate and let the render layer resolve it (invariant(semantic_palette_at_the_core_boundary)). Decide whether hints are worth the same treatment or whether they should simply move out of the protocol crate.","acceptance_criteria":"AppMode carries a semantic value the render layer resolves, the way AxisColor -\u003e draw.rs::axis_fg and MinibufColor -\u003e minibuf_fg already do, or hint_text/label move out of improvise-protocol entirely. Either way a non-terminal or localized client stops being handed English keybinding-naming strings on a serde wire enum. Decide together with improvise-x7sw and improvise-zx8t — all three are the same question about where UI strings live relative to a portable enum.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-h-frontend","owner":"el-github@elangley.org","created_at":"2026-09-19T20:42:38Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:18:07Z","started_at":"2026-09-20T17:53:40Z","closed_at":"2026-09-20T18:18:07Z","close_reason":"Closed","labels":["area:shared-frontend"],"dependency_count":0,"dependent_count":0,"comment_count":0} @@ -815,7 +954,7 @@ {"_type":"issue","id":"improvise-ct5","title":"i on an empty records grid reports 'cannot drill', leaking a pivot-mode concept","description":"In records mode every regular category sits on Axis::None (ToggleRecordsMode, command/cmd/grid.rs:432), so EditOrDrill's regular_none is always true and the edit branch depends entirely on is_synthetic. On an empty records grid there is no synthetic record at the cursor, so i takes the drill branch, finds no cell key, and sets 'cannot drill — no cell at cursor'.\n\nThe outcome (nothing to edit) is defensible, but the message talks about drilling — a pivot-mode concept the user is not in. In records mode the user's model is 'a table I type into', and o one key away does add a row and open an edit.\n\nOptions: (a) reword the message for records mode; (b) make i on an empty records grid behave like o. (b) is more helpful but makes i mode-dependent, which cuts against edit-or-drill being mode-agnostic — decide deliberately.\n\nPinned by ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid, which exists to record that i and o diverge here (see improvise-6sq). Changing this behavior means updating that test; read its doc-comment first — the divergence itself is load-bearing.","notes":"STALE REF (groomed 2026-08-30, line numbers shifted by this session's work): command/cmd/grid.rs:432 -\u003e the axis loop moved to crates/improvise-command/src/command/cmd/grid.rs:443 (the '!CategoryKind::of_name(name).is_index_or_dim()' loop in ToggleRecordsMode).","status":"closed","priority":4,"issue_type":"bug","assignee":"wave-1","owner":"el-github@elangley.org","created_at":"2026-08-29T17:52:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T07:54:39Z","started_at":"2026-09-20T05:32:33Z","closed_at":"2026-09-20T07:54:39Z","close_reason":"Closed by PR #27 (WU-G). Option (a): reword, do not make `i` behave like `o`.\n\nTHE DECISION AND WHY: the divergence between `i` and `o` on an empty records grid is what improvise-6sq exists to record, and it is why edit-or-drill and enter-edit-at-cursor are two commands. Option (b) collapses it. The OUTCOME (nothing to edit) was never the bug -- the WORDING was, because \"cannot drill\" leaks a pivot-mode concept into a mode whose model is \"a table I type into\".\n\nWHICH SENTENCE IS SHOWN is read off `edit_mode` -- THE ARGUMENT THE KEYMAP SUPPLIES -- not off ctx.mode, so no runtime is_records() check returns (improvise-4ju).\n\nEVIDENCE: the pinning test ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid keeps its load-bearing assertion (`i` does not enter editing) and was watched FAILING on the old message before the fix. New the_empty_grid_message_is_not_the_records_one_in_pivot_mode is the negative arm -- without it, \"say the records sentence everywhere\" passes, and would promise `o` in pivot mode where `o` adds no row. Fault injected by collapsing to one sentence; failed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ont","title":"Migrate existing callers of workbook.views.get() to Workbook::active_view","description":"Workbook already exposes active_view() and active_view_mut() (crates/improvise-core/crates/improvise-core/src/workbook.rs:87-97) but many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X or wb.views.get('view_name').unwrap().axis_of(...).\\n\\n11 lookup sites identified:\\n- crates/improvise-core/crates/improvise-core/src/workbook.rs:167-168, 176-177 (within workbook.rs itself — these may be the implementations and be fine)\\n- crates/improvise-core/crates/improvise-core/src/model/types.rs:1868, 1871, 1873, 1886, 1890 (tests)\\n- crates/improvise-io/crates/improvise-io/src/persistence/mod.rs:943, 946, 1165\\n\\nFix: audit each site; migrate to Workbook::active_view() / Workbook::view(name) / add Workbook::view_axis(view_name, cat_name) -\u003e Option\u003cAxis\u003e to absorb the common chain.\\n\\nMostly a cleanup task but reduces future refactor exposure. ~15 LOC.","notes":"Groomed 2026-08-13 — re-verified against the tree; the premise has largely evaporated.\n\nCurrent state: 14 `views.get(` call sites remain, and 13 of them are inside #[cfg(test)] modules (workbook.rs, model/types.rs, persistence/mod.rs). Test code asserting on a NAMED view is not the chain this ticket wants to remove — `active_view()` is the wrong tool there, because the whole point of those assertions is to look at a view that is not active.\n\nThe single production site is src/ui/view_panel.rs:31:\n let Some(view) = self.workbook.views.get(view_name) else { ... }\nThat takes a dynamic view name from the panel list, so active_view() does not apply to it either. At most it wants a `Workbook::view(name) -\u003e Option\u003c\u0026View\u003e` accessor, which is a 3-line change with no correctness content.\n\nThe ticket's original count of '11 lookup sites' predates the improvise-tvt item-facade work, which removed most of the interesting ones. What is left is not worth a P3 slot. Dropped to P4.\n\nRECOMMEND: either close as wont-do, or rescope to exactly 'add Workbook::view(name) and use it in view_panel.rs' — which is small enough to fold into whatever next touches that file.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:37Z","created_by":"Ed L","updated_at":"2026-08-30T17:07:07Z","closed_at":"2026-08-30T17:07:07Z","close_reason":"OBSOLETE — the pattern this ticket targets no longer exists (verified 2026-08-30 while grooming).\n\nThe description says 'many callers still do workbook.views.get(\u0026workbook.active_view).unwrap().X'. A grep for 'views.get(\u0026' across crates/ and src/ now returns ZERO matches; the migration to Workbook::active_view() has already happened, presumably in the crate-split work.\n\nOf the 14 remaining '.views.get(' call sites, 13 are in test modules (workbook.rs, persistence/mod.rs, model/types.rs — all asserting on a specific named view, which is the correct API for that) and exactly one is production code:\n\n src/ui/view_panel.rs:31 self.workbook.views.get(view_name)\n\nThat one looks up a view BY NAME from the panel's own list, so active_view() does not apply to it. There is nothing left to migrate.\n\nNot carried forward: the ticket also floated adding Workbook::view_axis(view_name, cat_name) to absorb the .unwrap().axis_of(...) chain. That chain now appears only in tests, so the absorption would buy little; file it fresh if it ever earns its keep.\n\nAlso noted while here: the description's paths were malformed — 'crates/improvise-core/crates/improvise-core/src/workbook.rs' — with the crate prefix doubled throughout, which is why they resolved to nothing.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-ete","title":"Add Model/Category getter methods to absorb .unwrap().items/groups chains","description":"Several callers reach through Model and Category to get item names, groups, or counts — each chain includes an .unwrap() that panics if the category is missing:\\n\\n- persistence/mod.rs:1635-1639, 2006-2007: model.category(name).unwrap().items.values().map(|i| i.name.clone()).collect() — 4 sites\\n- persistence/mod.rs:831: \u0026m2.model.category('Month').unwrap().groups — 1 site\\n- crates/improvise-command/src/ui/cat_tree.rs:32: cat.map(|c| c.items.len()) — item count\\n\\nAdd:\\n- Model::item_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Model::group_names(cat: \u0026str) -\u003e Option\u003cVec\u003cString\u003e\u003e\\n- Category::item_count() -\u003e usize\\n\\nCorrectness win: (a) prevents panics if the category lookup fails; (b) hides the items/groups IndexMap/HashMap from callers — internal-rep changes don't cascade; (c) the Option return type forces explicit handling.\\n\\n~15 LOC plus cleaner call sites.","notes":"GROOMED 2026-09-19 — DISSOLVED PREMISE, mostly. The four persistence/mod.rs sites this ticket was built on (model.category(name).unwrap().items.values()...) no longer exist in production code; grep for unwrap().items / unwrap().groups now hits only test modules (persistence/mod.rs is #[cfg(test)] from line 706 on, workbook.rs likewise). The stated correctness win — \"prevents panics if the category lookup fails\" — is therefore void: there is no production panic left to prevent. What survives is encapsulation in test code plus cat_tree.rs:32 (cat.map(|c| c.items.len()).unwrap_or(0)), which already handles the missing category. P3 -\u003e P4 on that basis; close it if the encapsulation alone is not worth 15 LOC.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-16T19:02:34Z","created_by":"Ed L","updated_at":"2026-09-19T20:52:14Z","closed_at":"2026-09-19T20:52:14Z","close_reason":"OBSOLETE -- premise dissolved, verified item by item rather than accepted from the groom's note. (1) Model::item_names would DUPLICATE the existing Model::effective_item_names, and in a worse form: the ticket's version reading category(name).items would silently omit formula targets for _Measure, which is exactly the trap gotcha 7 warns about; the existing one routes _Measure through measure_item_names and returns Vec rather than Option\u003cVec\u003e, which is also the right call under 'keep Option at the boundaries'. (2) Model::group_names has no caller inside improvise-core -- the site the ticket cites now lives in an improvise-io test. (3) Category::item_count has no in-scope caller either; the live one is improvise-command's cat_tree.rs, which already handles the missing category.\n\nThe ticket's stated correctness win -- 'prevents panics if the category lookup fails' -- is void: every unwrap().items / unwrap().groups chain in the repo is now inside a #[cfg(test)] module. The single production reader of cat.items.values() is persistence/format.rs, the serializer legitimately walking items to write them, which is not an unwrap chain and not something a getter should hide from a formatter.\n\nIf the encapsulation is still wanted for improvise-command's cat_tree.rs, that is one method for whoever owns that crate, not the three proposed here.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign. Orthogonal data-out path with no consumer asking for it; it sits behind the ADR 003 data-source direction (improvise-rucf), which is itself filed not-a-priority. Revisit if an interop request is concrete.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"improvise-a5q","title":"Parquet columnar export (read-only)","description":"One-way export to Apache Parquet for interop with data tools (pandas, Arrow, BI tools). Columnar layout maps naturally to improvise's category/measure/cell structure: each category becomes a column of the key space, each measure a value column. Read-back not required for MVP; this is primarily a data-out path. Orthogonal to the core standalone deployment; filed as a backlog follow-on.","design":"Use arrow-rs + parquet crates (or polars as a simpler wrapper). Model shape → Arrow RecordBatch: one row per cell, columns are (category_1, category_2, ..., measure_name, value). Write the batch to a parquet file via the VFS. Read-back (parquet → model) is a stretch goal — most users will export once and consume the file elsewhere. Browser build: parquet + arrow crates are heavy; may push the wasm bundle size significantly. Consider making parquet export a separate optional wasm chunk loaded on demand.","acceptance_criteria":"(1) Export function writes a valid parquet file from the current model. (2) File opens correctly in pandas / pyarrow and reproduces the improv data. (3) Works over VFS (native filesystem + OPFS).","notes":"Depends on improvise-6mq (VFS abstraction). Very low priority — a nice data-science handoff story but not required for core functionality. Filed as P4 backlog.\nDEFERRED 2026-09-19 by user ruling during the whole-backlog campaign. Orthogonal data-out path with no consumer asking for it; it sits behind the ADR 003 data-source direction (improvise-rucf), which is itself filed not-a-priority. Revisit if an interop request is concrete.","status":"deferred","priority":4,"issue_type":"feature","owner":"el-github@elangley.org","created_at":"2026-04-14T07:39:46Z","created_by":"spot","updated_at":"2026-09-20T05:31:03Z","dependencies":[{"issue_id":"improvise-a5q","depends_on_id":"improvise-6mq","type":"blocks","created_at":"2026-04-14T00:40:35Z","created_by":"spot","metadata":"{}"},{"issue_id":"improvise-a5q","depends_on_id":"improvise-8jk6","type":"parent-child","created_at":"2026-09-25T01:05:29Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"improvise-3gy","title":"4.2 Enable GitHub Pages","description":"Enable Pages in GitHub repo settings: source=main branch, folder=/docs. Verify site is live at the GitHub Pages URL.","notes":"CORRECTION (2026-09-20): this ticket's text says source=main branch, folder=/docs. That is the WRONG SETTING for the pipeline that landed in improvise-d31.\n\nThe correct setting is Settings -\u003e Pages -\u003e Source: GITHUB ACTIONS. The workflow uploads a Pages artifact from pages-build and deploys it with deploy-pages@v4; a branch/folder source would serve the repo instead and silently ignore the workflow.\n\nThe workflow attempts enablement itself (configure-pages@v5, enablement: true), so this may need no human at all -- verified as NOT currently enabled (the Pages API 404s). If the run fails at configure-pages, note that default_workflow_permissions on this repo is 'read'; the job declares permissions: {pages: write, id-token: write} explicitly, which should grant above the default, but it is the first thing to check on a 403.","status":"closed","priority":4,"issue_type":"task","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:10Z","created_by":"Edward Langley","updated_at":"2026-09-20T04:20:51Z","closed_at":"2026-09-20T04:20:51Z","close_reason":"Done. GitHub Pages is enabled and serving: https://fiddlerwoaroof.github.io/improvise/ renders the demo grid from the in-page worker, verified by the user in Safari 27 on 2026-09-20.\n\nNote the ticket's own text was WRONG about the setting and was corrected before this closed: it said source = main branch, folder = /docs. The pipeline improvise-d31 built uploads a Pages ARTIFACT and deploys it with deploy-pages@v4, so the correct source is GITHUB ACTIONS. configure-pages@v5 with enablement: true turned it on without a human touching the setting, which answers the open question about whether the workflow token may.","dependencies":[{"issue_id":"improvise-3gy","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T21:09:32Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"improvise-e61","title":"4.1 Create docs/index.html landing page","description":"Vanilla HTML, single file, under 200 lines. Dark background, monospace headings. Embed asciinema-player from jsdelivr CDN. Sections: title/tagline, pivot cast, drill cast, formulas cast, import cast, install commands + GitHub link. Player config: rows 30, cols 100, theme monokai, autoPlay false, loop true.","acceptance_criteria":"docs/index.html exists: vanilla HTML, single file, under 200 lines, dark background and monospace headings, asciinema-player embedded from jsdelivr, sections for title/tagline, the pivot/drill/formulas/import casts, install commands and a GitHub link. Player config rows 30, cols 100, theme monokai, autoPlay false, loop true. The four casts already exist (improvise-d4w is closed) — check they still reflect the current UI before embedding them, since the wave changed the records columns and the status line.","notes":"CRITERION REINTERPRETED, on review of PR #47, rather than a second bead filed.\n\n\"Under 200 lines\" is a PROXY for what this ticket actually cared about — single\nfile, vanilla, no build step, no framework — and those are the properties worth\nholding. The line count is not: it is met or missed by an arbitrary margin, it is\nchecked by nothing, and at 199 lines it was \"met\" only by accident, with the next\nperson to touch the file silently breaking it.\n\n`docs/index.html` is now 209 lines. The properties the number stood for are all\nintact: one file, no build step, no framework, no dependency but the pinned\nasciinema-player from a CDN.\n\nWhat took it over was a fix, not padding. At a 390px viewport the four players\ncomputed a 4.83px terminal font — measured in Chromium against the assembled\ntree — because a 120-column cast shrunk into a phone is unreadable. That was\ntolerable when the landing page sat at /landing/; since the user's ruling it is\nthe first thing a phone visitor sees. A media query under 900px now scrolls each\nplayer sideways at a legible size instead: 11.95px, measured the same way, with\nthe PAGE's own scrollWidth still equal to its clientWidth, so the overflow is\ncontained inside the player and there is no horizontal page scroll.\nTWO CORRECTIONS TO THE NOTE ABOVE, and one thing it should not be read as saying.\n\nFIRST, THE NUMBERS IN THAT NOTE ARE NOW WRONG. It says the file is 209 lines and\nthat a media query fixes the phone rendering. A user ruling has since reverted\nthat fix — the mobile defect is filed as improvise-o31u and the page is\nunchanged — so `docs/index.html` is **213 lines**, and what took it past 200 is\nthe drill-cast caption below, not the mobile fix. The criterion reinterpretation\nstands on its own reasoning and does not depend on which change crossed the\nline: \"under 200 lines\" was a proxy for single file, vanilla, no build step, no\nframework, and all four hold.\n\nSECOND, AND MORE IMPORTANT: THE CAPTION IS A LABEL ON AN UNMET CRITERION, NOT A\nMET ONE. This ticket asked that the casts be checked against the current UI\nbefore embedding. They were, and `drill.cast` fails: it shows the narrow records\nformat with a single `Value` column, which improvise-c5v and improvise-2zq\nreplaced with a column per measure plus the `+cat` and `+` columns. The user\nruled to ship it with a visible caption rather than re-record or drop the\nsection, and to keep this ticket closed. That ruling is about what to SHIP; it\ndoes not make the criterion satisfied.\n\nSo the honest reading of this ticket's state is: delivered, with one embedded\ncast known to demo a UI the software no longer has, labelled as such on the page\nat body size and above the player rather than in a footnote, and improvise-2oxt\nopen to fix it properly. Anyone auditing \"do the casts reflect the current UI\"\nshould read improvise-2oxt, not this close reason, for the answer.","status":"closed","priority":4,"issue_type":"task","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:08:07Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:22:56Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:48Z","close_reason":"docs/index.html on wave/p-milestones: 194 lines, vanilla, single file, dark\nbackground, monospace headings, asciinema-player 3.10.0 pinned from jsdelivr\n(both URLs checked live, 200), all six sections, and the four improvise-d4w\ncasts. Verified by driving it in Chromium against both docs/ and the assembled\nsite/landing/: four players construct, each renders 37 terminal lines after\nplay, zero failed requests, zero page errors. That check was proved able to\nfail first — with autoPlay false the cast is not fetched until the click, so\ncounting players passes on a page whose every cast path 404s; breaking\n./casts/ to ./cats/ passes the construction check and fails the play check.\nNothing in CI asserts it yet: improvise-bgtf.\n\nTWO DEVIATIONS, both stated rather than papered over.\n\nPlayer geometry is cols 120 / rows 37, not the 100x30 this ticket and\nimprovise-d4w both name. Every cast header says 120x37, so the recordings\nnever complied with d4w's own \"Terminal: 100x30\"; a 30-row player clips the\nbottom seven rows of a 37-row cast, which is where the tile bar and the status\nline are.\n\nThe \"check they still reflect the current UI\" criterion was performed and the\nanswer is partly no. The tile bar's short_label vocabulary and the mode line\nstill match; the casts predate improvise-2zq's +cat column by five months and\nevery one opens on the recorder's shell prompt and home directory. Filed as\nimprovise-2oxt rather than embedded as if current.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-e61","depends_on_id":"improvise-d4w","type":"blocks","created_at":"2026-04-08T21:09:31Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} {"_type":"issue","id":"improvise-kh8","title":"Phase 4: Landing page (optional)","description":"Create docs/index.html with embedded asciinema casts and enable GitHub Pages. Optional but recommended for launch.","acceptance_criteria":"Closes when improvise-e61 closes; improvise-3gy (enable GitHub Pages) is already closed. NOTE A COLLISION worth resolving before starting: Pages is already serving the standalone web app from the CI assemble step (improvise-d31/bck). Decide whether the landing page replaces that, sits beside it at a path, or is dropped in favour of the live app being the landing page — which is arguably the better demo and would close this as won't-fix.","notes":"CORRECTION TO THE CLOSE REASON ABOVE — THE USER OVERRULED IT, same session.\n\nThe close reason records the landing page sitting BESIDE the app at /landing/,\nwith the app keeping the root. The user ruled the other way:\n\n \"The landing page should be served at the root of the repository and link\n to the in-browser demo.\"\n\nSo the arrangement is inverted. `docs/index.html` is the site root and links\nprominently to the app, which moves to `/app/`. The argument for the other\narrangement is left standing above rather than deleted, because it is still the\nreason the alternative was considered and the collision this ticket asked to\nresolve was a genuine one — but it is OVERRULED, not merely superseded, and the\nroot is now the landing page.\n\nWHAT THE INVERSION COST, which is the part worth carrying:\n\n* The app moves under `app/` WHOLE — `index.html`, `app.js`, `style.css`,\n `worker.js`, `demo.improv` and BOTH bundle directories. Leaving `pkg/` and\n `worker-pkg/` at the root would have meant editing the wasm-pack generated\n glue; every path in app.js and worker.js is relative, so moving the bundle\n together keeps all of them correct and the glue untouched. `worker.js`'s\n `fetch(\"demo.improv\")` resolves against the WORKER's url, so the seed follows\n the move for the same reason.\n* `web/assemble.sh`'s four `stamped` guards had to follow the files. They are\n the only check that the cache-bust substitution worked, and a guard pointed at\n a path nothing writes fails in the direction that looks like success.\n* `harness/tests/shell.spec.js` navigates to `/index.html` six times, including\n the `?ws=` and `?doc=` variants. Every one of those now lands on the landing\n page, so all six were repointed at `/app/index.html`. The browser harness is a\n CI job that can fail the build, so this was not optional and not someone\n else's to discover.\n\nVERIFIED against the ASSEMBLED tree, not against docs/ directly, because a path\nmove is exactly what re-arms the trap this page already caught once: the four\ncasts play 37 lines each at the root, the primary call-to-action resolves to\n/app/ and returns 200, and there are no failed requests and no page errors. The\ncall-to-action check was proved able to fail by pointing the link at ./aap/,\nwhich returns 404 while the page still looks entirely correct.","status":"closed","priority":4,"issue_type":"feature","assignee":"wave2-p-milestones","owner":"el-github@elangley.org","created_at":"2026-04-09T04:05:53Z","created_by":"Edward Langley","updated_at":"2026-09-20T18:10:48Z","started_at":"2026-09-20T18:02:40Z","closed_at":"2026-09-20T18:04:51Z","close_reason":"Both children terminal: improvise-3gy was already closed, improvise-e61 closes\nwith this wave.\n\nTHE COLLISION THIS TICKET ASKED TO RESOLVE FIRST, resolved: Pages serves the\nstandalone web app at the root (improvise-d31/bck), and the landing page now\nsits BESIDE it at /landing/ rather than replacing it. The reasoning is this\nticket's own — the live app is the better front door — and the third option,\ndropping the landing page in favour of the app, was declined only because the\nrecorded terminal casts show the TUI, which the browser app does not.\n\nweb/assemble.sh copies docs/index.html and docs/casts/ into the deployed tree,\nso the page is reachable and the browser harness serves the same tree the\ndeploy publishes. A landing page the deploy never copies would have satisfied\nthis ticket's letter and been a file nobody can open.\n\nREADME links both.","labels":["area:release"],"dependencies":[{"issue_id":"improvise-kh8","depends_on_id":"improvise-3gy","type":"blocks","created_at":"2026-04-08T23:37:42Z","created_by":"Edward Langley","metadata":"{}"},{"issue_id":"improvise-kh8","depends_on_id":"improvise-e61","type":"blocks","created_at":"2026-04-08T23:37:41Z","created_by":"Edward Langley","metadata":"{}"}],"dependency_count":2,"dependent_count":0,"comment_count":0} diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh index 52b0e9c2..dea08886 100755 --- a/.claude/hooks/session-start.sh +++ b/.claude/hooks/session-start.sh @@ -1,273 +1,62 @@ -#!/bin/bash +#!/usr/bin/env bash # Prepare a Claude Code on the web container for work on this repository. # -# Section 0 runs everywhere, cloud or not: it asserts that the `bd` on PATH -# is the one flake.nix pins. Everything after it is cloud-only setup. +# This hook delegates to the shared `scripts/agent-bootstrap.sh`, which works +# for any coding agent stack (Claude Code, Cursor, etc.). This file exists +# because Claude Code expects hooks at `.claude/hooks/session-start.sh`. # -# Three things are slow or wrong by default in a fresh cloud session: +# For local checkouts, only the bd pin assertion runs. +# For cloud sessions (CLAUDE_CODE_REMOTE=true), full bootstrap runs. # -# 1. `bd` is not installed, so the `bd prime` hook and every `bd` command -# in AGENTS.md silently do nothing. -# 2. `.beads/issues.jsonl` is a stale export. An agent that reads it picks -# work that is already done — this has happened. The authoritative -# tracker is the Dolt database on the git remote. -# 3. The Rust workspace is uncompiled, so the first `cargo test` pays a -# cold build before it reports anything. -# -# The tracker is re-synced on every session, not just when the database is -# missing: cloud containers reuse a cached workspace, so "already present" -# does not mean "still current", and a silently ageing tracker is the exact -# failure this hook exists to prevent. Installing bd and warming the build -# do check first, being genuinely one-time. -# -# Nothing here writes to a tracked file, stages anything, or creates a commit. +# See also: scripts/agent-bootstrap.sh (the stack-agnostic implementation) set -uo pipefail ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}" -cd "$ROOT" || exit 0 +BOOTSTRAP="$ROOT/scripts/agent-bootstrap.sh" -log() { printf '[session-start] %s\n' "$*"; } - -# ── 0. The bd on PATH must be the bd flake.nix pins ──────────────────────── -# `flake.nix` pins beads so that one build of `bd` touches this repo. A git -# worktree does not load that pin — a fresh `.envrc` is unallowed, so a -# process there falls through to whatever `bd` the user's profile happens to -# carry. Today those agree; nothing enforces that they keep agreeing. -# -# The failure that matters is silent: writing .beads/issues.jsonl with a -# skewed build empties the dependency graph and reports success. Nobody -# notices until they ask what blocks what. So the mismatch is turned into a -# loud one here rather than trusted. Reading with a skewed bd is safe — it -# is the mutating commands that corrupt — so this blocks nothing, it shouts. -# -# The expected version is read from flake.nix, not written down twice: a -# hard-coded copy would go stale the first time the pin moves, and a stale -# assertion is worse than none. -beads_pin() { - sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \ - flake.nix 2>/dev/null | head -1 -} - -bd_running_version() { - bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1 -} - -bd_pin_banner() { - { - printf '\n[session-start] ===== BEADS VERSION SKEW =====\n' - printf '[session-start] %s\n' "$@" - printf '[session-start] =============================\n\n' - } >&2 -} - -# Prints the banner and returns 1 when the two disagree, or when either one -# cannot be read — an unreadable answer is a rotted assertion, not a pass. -assert_bd_pin() { - command -v bd >/dev/null 2>&1 || return 0 # nothing on PATH to assert about - - pinned=$(beads_pin) - running=$(bd_running_version) - - case "$pinned" in - [0-9]*.[0-9]*.[0-9]*) ;; - *) - bd_pin_banner "cannot read the beads pin out of flake.nix" \ - "read: '${pinned:-}'" \ - "The pin moved to a ref this hook cannot parse (a branch or a" \ - "commit, perhaps). Teach beads_pin() to read it, or the check" \ - "silently protects nothing." - return 1 - ;; - esac - - if [ -z "$running" ]; then - bd_pin_banner "cannot read the version of the bd on PATH" \ - "bd: $(command -v bd)" \ - "'bd version' did not answer in the expected shape. Until it does," \ - "nothing here can tell whether that build matches the pin." - return 1 - fi - - [ "$running" = "$pinned" ] && return 0 - - bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \ - "pinned (flake.nix): $pinned" \ - "running: $running ($(command -v bd))" \ - "" \ - "Reading is safe. Do NOT run a mutating bd command — create, update," \ - "close, dep, dolt push. Version skew between builds empties the" \ - "dependency graph in .beads/issues.jsonl and reports success." \ - "" \ - "Fix now: nix develop (in this directory), then re-run the" \ - " command; or 'direnv allow' here." \ - "Fix for good: worktrees are created fresh per dispatch, so allowing" \ - " them one at a time never catches the next one. Put the" \ - " repo root under [whitelist] prefix in" \ - " ~/.config/direnv/direnv.toml." - return 1 -} - -pin_status=0 -assert_bd_pin || pin_status=2 - -# Local checkouts have their own setup; this is for the web containers. -if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then - exit "$pin_status" -fi - -# Install a verified database at .beads/embeddeddolt. -# -# The staged copy lives on another filesystem, so it is brought alongside the -# live one FIRST. Every step after that is a same-filesystem rename, which -# either happens or does not — no half-finished copy. The previous database is -# removed only once the new one is in place, so no failure path leaves the repo -# without a tracker, and every move is checked: an unchecked one would let the -# new database land *inside* the old directory and still report success. -install_tracker() { - staged=$1 - live=.beads/embeddeddolt - incoming=$live.incoming - previous=$live.previous - - rm -rf "$incoming" "$previous" || return 1 - cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; } - - if [ -d "$live" ] && ! mv "$live" "$previous"; then - rm -rf "$incoming" - return 1 - fi - - if mv "$incoming" "$live"; then - rm -rf "$previous" - return 0 - fi - - # Could not put the new one in place; restore what was there. - if [ -d "$previous" ] && ! mv "$previous" "$live"; then - log "ERROR: tracker left at $previous — move it back to $live by hand" - fi - rm -rf "$incoming" - return 1 -} - -# ── 1. bd on PATH ────────────────────────────────────────────────────────── -# The embedded Dolt engine needs CGO, so this builds from source (~3 min). -# The container image is cached after the hook, so it is paid once. -export PATH="$PATH:/root/go/bin:$HOME/go/bin" -# Pinned to a commit rather than `main`, and downloaded before it is run: -# piping a moving URL straight into a shell executes code nobody has seen, -# and leaves nothing to inspect afterwards. The installer stays at -# /tmp/bd-install.sh so what actually ran can be read. -# -# To take a newer beads, resolve the peeled commit of its tag: -# git ls-remote --tags https://github.com/steveyegge/beads | grep 'v1.3.0^{}' -BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0 -BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh" - -if ! command -v bd >/dev/null 2>&1; then - log "installing bd (beads v1.3.0)…" - if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then - log "WARNING: could not download the bd installer; see /tmp/bd-install.log." - elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then - log "bd installed" - else - log "WARNING: bd install failed; see /tmp/bd-install.log (the script that" - log " ran is at /tmp/bd-install.sh). Issue tracking unavailable." - fi -fi +# Forward the Claude env file variable to the generic name the bootstrap uses if [ -n "${CLAUDE_ENV_FILE:-}" ]; then - # Single quotes are deliberate: $PATH and $HOME must be written literally so - # they expand when the session sources this, not now. - # shellcheck disable=SC2016 - echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$CLAUDE_ENV_FILE" -fi -command -v bd >/dev/null 2>&1 && bd metrics off >/dev/null 2>&1 - -# bd may have arrived only just now, in which case §0 had nothing to check. -# Re-check, but only if §0 stayed quiet, so the banner is never printed twice. -if [ "$pin_status" -eq 0 ]; then - assert_bd_pin || pin_status=2 + export AGENT_ENV_FILE="$CLAUDE_ENV_FILE" fi -# ── 2. Sync the issue tracker from the Dolt remote ───────────────────────── -# `bd init` in-place rewrites .beads/config.yaml and commits, so the sync -# happens in a throwaway directory and only the database is moved back. -# That directory must be named after the database (see .beads/metadata.json), -# since bd derives the database name from it. -if command -v bd >/dev/null 2>&1; then - DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)" - ORIGIN="$(git remote get-url origin 2>/dev/null || true)" - case "$ORIGIN" in - git@github.com:*) ORIGIN="https://github.com/${ORIGIN#git@github.com:}" ;; - esac - ORIGIN="${ORIGIN%.git}" - - # The mktemp is part of the condition on purpose. Unchecked, a failure - # leaves TMP empty, "$TMP/$DB_NAME" becomes an absolute path at the - # filesystem root, and the cleanup at the end degrades to `rm -rf ""`. - # Short-circuiting also means no temp directory is created when there is - # no origin to sync from. - if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then - log "syncing issue tracker from $ORIGIN …" - STAGE="$TMP/$DB_NAME" - mkdir -p "$STAGE" - - # `bd init` exits non-zero when the remote is unreachable but STILL leaves - # a .beads/embeddeddolt behind — an empty database. Testing only for the - # directory would swap that over a good tracker, so check the status and - # confirm the result actually carries issues. An empty remote tracker is - # indistinguishable from a failed clone here; keeping what we already have - # is the safe way to be wrong. - sync_ok=0 - if ( - cd "$STAGE" && git init -q . && - BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \ - --remote "git+$ORIGIN" - ) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then - listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )" - case "$listing" in - "" | "No issues found."*) ;; - *) sync_ok=1 ;; - esac +# For local checkouts, only verify the bd pin (no install, no sync) +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + # Inline the pin-check only, since the full bootstrap is cloud-only + cd "$ROOT" || exit 0 + beads_pin() { + sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \ + flake.nix 2>/dev/null | head -1 + } + bd_running_version() { + bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1 + } + if command -v bd >/dev/null 2>&1; then + pinned=$(beads_pin) + running=$(bd_running_version) + # Fail closed: if we can't parse either version, warn rather than silently pass + if [ -z "$pinned" ]; then + printf '[session-start] WARNING: cannot parse beads pin from flake.nix\n' >&2 + printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2 + exit 2 fi - - if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then - log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl" - elif [ -d .beads/embeddeddolt ]; then - log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database." - log " It may be out of date — verify any bead against the code." - else - log "WARNING: tracker sync failed; see /tmp/bd-sync.log." - log " .beads/issues.jsonl is a STALE export — verify any bead against the code." + if [ -z "$running" ]; then + printf '[session-start] WARNING: cannot parse bd version from "bd version"\n' >&2 + printf '[session-start] bd: %s\n' "$(command -v bd)" >&2 + printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2 + exit 2 + fi + if [ "$running" != "$pinned" ]; then + printf '\n[session-start] ===== BEADS VERSION SKEW =====\n' >&2 + printf '[session-start] pinned (flake.nix): %s\n' "$pinned" >&2 + printf '[session-start] running: %s (%s)\n' "$running" "$(command -v bd)" >&2 + printf '[session-start] Reading is safe. Do NOT run mutating bd commands.\n' >&2 + printf '[session-start] Fix: nix develop, or direnv allow.\n' >&2 + printf '[session-start] =============================\n\n' >&2 + exit 2 fi - rm -rf "$TMP" - elif [ -n "$ORIGIN" ]; then - log "WARNING: could not create a temporary directory; skipped the tracker sync." - log " .beads/issues.jsonl is a STALE export — verify any bead against the code." fi + exit 0 fi -# ── 3. Warm the build so the first cargo test is not a cold compile ──────── -if command -v cargo >/dev/null 2>&1; then - log "warming cargo build…" - if cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then - log "workspace built" - else - log "WARNING: cargo build failed; see /tmp/cargo-warm.log" - fi -fi - -# ── 4. Environment facts worth knowing up front ──────────────────────────── -cat <<'NOTES' -[session-start] Environment notes (see context/cloud-agents.md): - • Use the preinstalled cargo toolchain for ordinary work. `nix develop` - also works, but only after scripts/nix-seed-flake-inputs.sh — GitHub - tarballs are not served here, so the locked inputs are seeded over git. - • `bd dolt push` is blocked outbound (403). Bead status changes stay local. - • Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md - and .claude/settings.json, and commits. Use --skip-agents --skip-hooks. - • `bd dolt push ` ignores its argument and pushes to origin. -NOTES - -# Non-zero only when §0 found version skew; the setup above still ran. -exit "$pin_status" +# Cloud session: run the full bootstrap +exec "$BOOTSTRAP" diff --git a/.claude/skills/beads b/.claude/skills/beads new file mode 120000 index 00000000..f57add10 --- /dev/null +++ b/.claude/skills/beads @@ -0,0 +1 @@ +../../.agents/skills/beads \ No newline at end of file diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 00000000..cf5e0431 --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,5 @@ +{ + "name": "Improvise", + "install": "nix-daemon >/tmp/nix-daemon.log 2>&1 & ./scripts/agent-bootstrap.sh", + "start": "nix-daemon &" +} diff --git a/.cursor/skills/beads b/.cursor/skills/beads new file mode 120000 index 00000000..f57add10 --- /dev/null +++ b/.cursor/skills/beads @@ -0,0 +1 @@ +../../.agents/skills/beads \ No newline at end of file diff --git a/.cursor/skills/beads-merge b/.cursor/skills/beads-merge new file mode 120000 index 00000000..53a96ecc --- /dev/null +++ b/.cursor/skills/beads-merge @@ -0,0 +1 @@ +../../.agents/skills/beads-merge \ No newline at end of file diff --git a/.cursor/skills/code-review b/.cursor/skills/code-review new file mode 120000 index 00000000..2d88efdf --- /dev/null +++ b/.cursor/skills/code-review @@ -0,0 +1 @@ +../../.agents/skills/code-review \ No newline at end of file diff --git a/.cursor/skills/kb b/.cursor/skills/kb new file mode 120000 index 00000000..3ee2d209 --- /dev/null +++ b/.cursor/skills/kb @@ -0,0 +1 @@ +../../.agents/skills/kb \ No newline at end of file diff --git a/.cursor/skills/pr-watch b/.cursor/skills/pr-watch new file mode 120000 index 00000000..3ef371ce --- /dev/null +++ b/.cursor/skills/pr-watch @@ -0,0 +1 @@ +../../.agents/skills/pr-watch \ No newline at end of file diff --git a/.cursor/skills/wave b/.cursor/skills/wave new file mode 120000 index 00000000..0c2044da --- /dev/null +++ b/.cursor/skills/wave @@ -0,0 +1 @@ +../../.agents/skills/wave \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dd44856..b295f781 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,13 +146,39 @@ jobs: # dependency crate each run. The NixOS runners substitute from titan's # harmonia first, and titan builds gh/main's `#improvise` hourly, so a PR that # leaves Cargo.lock alone rebuilds only the workspace crates it changed. + # + # The repo-stats pipeline runs here because it needs this job's binary: + # `.#repo-stats-tools` is that build plus code-maat and its JRE, which stay + # out of the ci shell every other job enters. + # Its suite gates; the report on a push to main never fails the build, and + # its step fails only when improvise refuses the pipeline's input + # (context/code/tooling.md section 7). Full history is for the report. nix-build: - name: Nix build (crate2nix) + name: Nix build (crate2nix) and the repo-stats pipeline runs-on: [self-hosted, nixos, microvm] steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: nix build .#improvise run: nix build --print-build-logs .#improvise + - name: The repo-stats pipeline computes what it claims to + run: nix shell .#repo-stats-tools --command ./scripts/checkers.sh --ci nix-build + - name: Churn, coupling and doc-growth report (report only) + if: github.event_name == 'push' + continue-on-error: true + run: | + rc=0 + nix shell .#repo-stats-tools --command ./scripts/repo-stats --out repo-stats > repo-stats.txt || rc=$? + { echo '```'; cat repo-stats.txt; echo '```'; } >> "$GITHUB_STEP_SUMMARY" + exit "$rc" + - name: Upload the repo-stats model and its CSVs + if: github.event_name == 'push' + uses: actions/upload-artifact@v7 + with: + name: repo-stats + path: repo-stats + retention-days: 30 wasm: name: wasm32 check (browser bundle crates) diff --git a/.github/workflows/cloud-agent-smoke.yml b/.github/workflows/cloud-agent-smoke.yml new file mode 100644 index 00000000..ec8bc4d7 --- /dev/null +++ b/.github/workflows/cloud-agent-smoke.yml @@ -0,0 +1,70 @@ +name: Cloud agent smoke + +on: + pull_request: + paths: + - ".claude/**" + - ".cursor/**" + - "scripts/agent-bootstrap.sh" + - "scripts/nix-seed-flake-inputs.sh" + - ".github/workflows/cloud-agent-smoke.yml" + +jobs: + claude: + name: Claude entry point + bead + runs-on: [self-hosted, nixos, microvm] + steps: + - uses: actions/checkout@v7 + - name: Bootstrap through Claude session-start hook + shell: bash + env: + CLAUDE_CODE_REMOTE: "true" + run: | + if ! ./.claude/hooks/session-start.sh; then + echo "=== /tmp/nix-daemon.log ===" + cat /tmp/nix-daemon.log 2>/dev/null || true + echo "=== /tmp/nix-seed.log ===" + cat /tmp/nix-seed.log 2>/dev/null || true + echo "=== /tmp/bd-install.log ===" + cat /tmp/bd-install.log 2>/dev/null || true + exit 1 + fi + - name: Create, read back, and close a smoke-test bead + shell: bash + run: | + set -euo pipefail + out="$(bd create "Cloud agent smoke: Claude entry point" --description="Ephemeral local-only bead used to verify the Claude cloud bootstrap entry point." --type=task --priority=4 --json)" + id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')" + test -n "$id" + bd show "$id" + bd close "$id" --reason="Cloud-agent smoke test completed" + bd show "$id" | grep -qi closed + + cursor: + name: Cursor install entry point + bead + runs-on: [self-hosted, nixos, microvm] + steps: + - uses: actions/checkout@v7 + - name: Bootstrap through Cursor install command + shell: bash + run: | + nix-daemon >/tmp/nix-daemon.log 2>&1 & + if ! ./scripts/agent-bootstrap.sh; then + echo "=== /tmp/nix-daemon.log ===" + cat /tmp/nix-daemon.log 2>/dev/null || true + echo "=== /tmp/nix-seed.log ===" + cat /tmp/nix-seed.log 2>/dev/null || true + echo "=== /tmp/bd-install.log ===" + cat /tmp/bd-install.log 2>/dev/null || true + exit 1 + fi + - name: Create, read back, and close a smoke-test bead + shell: bash + run: | + set -euo pipefail + out="$(bd create "Cloud agent smoke: Cursor entry point" --description="Ephemeral local-only bead used to verify the Cursor cloud bootstrap entry point." --type=task --priority=4 --json)" + id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')" + test -n "$id" + bd show "$id" + bd close "$id" --reason="Cloud-agent smoke test completed" + bd show "$id" | grep -qi closed diff --git a/.gitignore b/.gitignore index 4c96598c..bb0779c1 100644 --- a/.gitignore +++ b/.gitignore @@ -61,6 +61,11 @@ GSYMS __pycache__/ *.pyc +# The assembled site, written by web/assemble.sh (and the doc generator's +# default OUT_DIR). Build output: CI assembles it from source (improvise-1xfb.4). +/site/ + # The release web capture, written to the repo root by # scripts/build-web-release.sh (improvise-oujb). /improvise-web.tar.gz +.opencode diff --git a/AGENTS.md b/AGENTS.md index a476b158..b5c5e18a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,10 @@ force-push. The rule, the message convention and the incidents are `.agents/skills/wave/agent-rules.md`, rule 8. - always start responses with bananaS! -- there is a prolog MCP available: load a session from `kb` at the root of the repository at the beginning of - every session, and query it as you work. **Reading only** — the MCP must never write `kb.pl`. +- **Knowledge base: use `scripts/kb`, not Prolog MCP.** The Prolog MCP is optional and not + available in all agent stacks (Cursor cloud agents do not have it). All agents must use + `scripts/kb add|replace|rm|query|check` for KB operations — see the `kb` skill at + `.agents/skills/kb/SKILL.md`. Reading `prolog-sessions/kb.pl` directly is fine for queries. - save structured facts as you go — work done, interesting locations, important decisions — with `scripts/kb add` (see the `kb` skill). Write a fact when you learn it; there is no reason to batch. - query the knowledge base as needed and use Prolog reasoning when it makes sense. @@ -25,6 +27,87 @@ This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. +## Multi-Stack Agent Support + +This repo supports multiple coding-agent stacks: Claude Code, Cursor (IDE and cloud), and +potentially others. All stacks share the same operational setup: + +| Component | Location | Notes | +|---|---|---| +| Toolchain | `nix develop` / `direnv` | **Required.** Rust from flake, wasm target, beads pin, trealla | +| Skills | `.agents/skills/` | Canonical source; `.claude/skills/` and `.cursor/skills/` symlink here | +| Session bootstrap | `scripts/agent-bootstrap.sh` | Seeds nix inputs, installs bd, syncs tracker, warms cargo | +| Knowledge base | `scripts/kb` | **All stacks use this**, not Prolog MCP | +| Issue tracker | `bd` CLI | Synced from Dolt remote at session start | + +### Toolchain Requirement + +**Agents must work inside `nix develop` (or direnv) for this repo.** Bare system cargo that +fails `edition2024` or missing wasm target is a misconfigured session. The bootstrap seeds +nix flake inputs automatically (GitHub tarballs are blocked in cloud), so `nix develop` +should work after install completes. + +Run commands inside the dev shell: +```bash +nix develop --command cargo test +nix develop --command cargo build --workspace +``` + +Or enter an interactive shell: `nix develop` + +**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically. + +**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are +discovered from `.cursor/skills/` (symlinks to `.agents/skills/`). + +**Local agents**: Run `scripts/agent-bootstrap.sh` manually, or use `direnv allow` for +automatic dev shell activation. + +See `context/cloud-agents.md` for environment-specific notes (nix, network, etc.). + +### GitHub Attribution (Hard Rule) + +**Every GitHub write (PR, comment, review reply) must include an attribution line identifying +the AI agent.** Posts go out under the repository owner's account, and an unattributed post +reads as the human owner's words to everyone who sees it. + +**Format:** Open the body with an italicized attribution line: + +``` +_Posted by ()_ +``` + +Examples: +- `_Posted by Claude (Opus 4.5)_` +- `_Posted by Cursor Agent (claude-sonnet-5)_` +- `_Posted by Claude Code_` + +For PR bodies, the `🤖 Generated with …` footer that some tools add covers this. For +comments and review replies, add the line yourself — there is no automatic footer. + +**This applies to:** PR creation, PR updates, issue comments, review comments, and review +replies. A thread is not "answered" until the reply carries this attribution. + +### PR Tracking: Checks AND Reviews + +For any PR an agent owns or is working on, **track both CI checks and GitHub comments/reviews**. +The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe. + +**`scripts/pr-watch`** now prints reviews after checks are green (use `--no-reviews` to skip). +Reviews are a separate API surface from comments — Codex/ChatGPT reviews arrive as *reviews*, +not comments, and are easy to miss if you only read the comment thread. + +1. **Run `scripts/pr-watch `** — waits for checks, then prints all reviews/comments +2. **Read all review feedback** — reviews against an earlier head may still apply +3. **Answer substantive feedback with attribution** before merging (see format above) +4. **Re-check after each push** that new reviews target the current head SHA +5. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows + +To fetch reviews directly: `./scripts/pr-reviews ` — **do not pipe it** (exit code trap). + +See the `pr-watch` skill for the full recipe, and "Shipping a wave" below for Copilot +request mechanics and how to answer declined findings. + ## Quick Reference ```bash diff --git a/Cargo.lock b/Cargo.lock index 961d6d68..77a3c5b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -854,7 +854,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-width", ] @@ -886,6 +886,7 @@ dependencies = [ "serde", "serde_json", "siphasher", + "unicase", ] [[package]] @@ -1044,9 +1045,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -1061,7 +1062,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1605,7 +1606,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -1892,9 +1893,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" @@ -2072,11 +2073,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -2092,9 +2093,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -2173,7 +2174,7 @@ dependencies = [ "log", "rand 0.10.3", "sha1", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2194,6 +2195,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -2282,9 +2289,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2295,19 +2302,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2315,9 +2323,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -2328,18 +2336,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2525,18 +2533,18 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "c17e8fafad82b542ff3717217ecdc736231b59e387768c9630123b4ce4d2db44" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "595f56e044df4f46a0c9a626f65c3d99eb8488f7e8a8baa12dd76326d9710bf2" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.nix b/Cargo.nix index 4478da93..b78b6f31 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -2657,7 +2657,7 @@ rec { } { name = "thiserror"; - packageId = "thiserror 2.0.20"; + packageId = "thiserror 2.0.21"; } { name = "unicode-width"; @@ -2668,6 +2668,7 @@ rec { { name = "improvise-protocol"; packageId = "improvise-protocol"; + features = [ "test-support" ]; } { name = "improvise-wasm-client"; @@ -2751,6 +2752,11 @@ rec { } ]; devDependencies = [ + { + name = "improvise-core"; + packageId = "improvise-core"; + features = [ "test-support" ]; + } { name = "improvise-protocol"; packageId = "improvise-protocol"; @@ -2791,6 +2797,10 @@ rec { name = "siphasher"; packageId = "siphasher"; } + { + name = "unicase"; + packageId = "unicase"; + } ]; devDependencies = [ { @@ -2802,7 +2812,9 @@ rec { packageId = "serde_json"; } ]; - + features = { + }; + resolvedDefaultFeatures = [ "test-support" ]; }; "improvise-formula" = rec { crateName = "improvise-formula"; @@ -3032,6 +3044,11 @@ rec { name = "improvise-core"; packageId = "improvise-core"; } + { + name = "improvise-protocol"; + packageId = "improvise-protocol"; + features = [ "test-support" ]; + } { name = "improvise-ws-server"; packageId = "improvise-ws-server"; @@ -3293,9 +3310,9 @@ rec { }; "js-sys" = rec { crateName = "js-sys"; - version = "0.3.105"; + version = "0.3.106"; edition = "2021"; - sha256 = "17pr58p55pxbflkjl2wz6pyz854j80nlgavjq4mcwr583q6x4myf"; + sha256 = "1icwmpjw54lb7vg5926k5y4y5jbiih0zxhwgjwnzn475v90xk0vq"; libName = "js_sys"; authors = [ "The wasm-bindgen Developers" @@ -3347,7 +3364,7 @@ rec { } { name = "thiserror"; - packageId = "thiserror 2.0.20"; + packageId = "thiserror 2.0.21"; usesDefaultFeatures = false; } ]; @@ -4896,7 +4913,7 @@ rec { } { name = "thiserror"; - packageId = "thiserror 2.0.20"; + packageId = "thiserror 2.0.21"; usesDefaultFeatures = false; } { @@ -5877,9 +5894,9 @@ rec { }; "smallvec" = rec { crateName = "smallvec"; - version = "1.16.1"; + version = "1.16.2"; edition = "2018"; - sha256 = "14gqvsqdli51r1bii3hfqv5vx1b9r0gic4br0x9fsixmy5b70ims"; + sha256 = "13iai5hhwyp8z0pbn8r11q4j5956jaxhcbvvf2drm17f1q7myfgr"; authors = [ "The Servo Project Developers" ]; @@ -6413,18 +6430,18 @@ rec { ]; }; - "thiserror 2.0.20" = rec { + "thiserror 2.0.21" = rec { crateName = "thiserror"; - version = "2.0.20"; + version = "2.0.21"; edition = "2021"; - sha256 = "0kxs6p295jffxhzaxpxv1dwaaf5iqlm6sx8h0djp6ancbxgj71pc"; + sha256 = "17hq1lh5dyr3bkc7zzjrbrp4qgkvhc48kgq1n5fdxkindaw2rr89"; authors = [ "David Tolnay " ]; dependencies = [ { name = "thiserror-impl"; - packageId = "thiserror-impl 2.0.20"; + packageId = "thiserror-impl 2.0.21"; } ]; features = { @@ -6458,11 +6475,11 @@ rec { ]; }; - "thiserror-impl 2.0.20" = rec { + "thiserror-impl 2.0.21" = rec { crateName = "thiserror-impl"; - version = "2.0.20"; + version = "2.0.21"; edition = "2021"; - sha256 = "1bwjc94gi0xn5jz26h1a8bjj1wdkvvr6jifamyc4mp9n28zcs15w"; + sha256 = "0945n8agp7kg6n6b35yyjb4g5xv2q22vrw15h6jj1nw76a99flgy"; procMacro = true; libName = "thiserror_impl"; authors = [ @@ -6812,7 +6829,7 @@ rec { } { name = "thiserror"; - packageId = "thiserror 2.0.20"; + packageId = "thiserror 2.0.21"; } ]; devDependencies = [ @@ -6874,6 +6891,17 @@ rec { sha256 = "154smf048k84prsdgh09nkm2n0w0336v84jd4zikyn6v6jrqbspa"; }; + "unicase" = rec { + crateName = "unicase"; + version = "2.9.0"; + edition = "2018"; + sha256 = "0hh1wrfd7807mfph2q67jsxqgw8hm82xg2fb8ln8cvblkwxbri6v"; + authors = [ + "Sean McArthur " + ]; + features = { + }; + }; "unicode-ident" = rec { crateName = "unicode-ident"; version = "1.0.26"; @@ -7114,9 +7142,9 @@ rec { }; "wasm-bindgen" = rec { crateName = "wasm-bindgen"; - version = "0.2.128"; + version = "0.2.129"; edition = "2021"; - sha256 = "1gsi4ggm03dqr3j9bcc1sbxzmkw6ykm3cqx4g8xmw31v7niqgjxf"; + sha256 = "02flhqld01jqb6vbfyx1gb8s78g1pnq2164daxad93y6mhrlzdcv"; libName = "wasm_bindgen"; authors = [ "The wasm-bindgen Developers" @@ -7147,12 +7175,6 @@ rec { rename = "rustversion-compat"; } ]; - devDependencies = [ - { - name = "once_cell"; - packageId = "once_cell"; - } - ]; features = { "default" = [ "std" ]; "enable-interning" = [ "std" ]; @@ -7165,9 +7187,9 @@ rec { }; "wasm-bindgen-futures" = rec { crateName = "wasm-bindgen-futures"; - version = "0.4.78"; + version = "0.4.79"; edition = "2021"; - sha256 = "0a6r1q1v2qvi3ypmy0nva3il422iyml1f61i897wbxfdsb9wbx3f"; + sha256 = "03k1wcg5j3wqk6vgy6gzcq868vsa9k81dlhqissfk0nrw96v7fiw"; libName = "wasm_bindgen_futures"; authors = [ "The wasm-bindgen Developers" @@ -7178,6 +7200,13 @@ rec { packageId = "js-sys"; usesDefaultFeatures = false; } + { + name = "tokio"; + packageId = "tokio"; + usesDefaultFeatures = false; + target = { target, features }: (("emscripten" == target."os" or null) && (target."wasm_bindgen_unstable_tokio" or false)); + features = [ "rt" "time" ]; + } { name = "wasm-bindgen"; packageId = "wasm-bindgen"; @@ -7193,9 +7222,9 @@ rec { }; "wasm-bindgen-macro" = rec { crateName = "wasm-bindgen-macro"; - version = "0.2.128"; + version = "0.2.129"; edition = "2021"; - sha256 = "1vdrjrb7yqh8p0r0yr93rlaq9iq05hy1wl9kbsjv7a61wc8xb456"; + sha256 = "0dc5xq09sy1v9ns1fhb0cnyqz5p1wcjjvkdmxskj9qhnbg1x0a9f"; procMacro = true; libName = "wasm_bindgen_macro"; authors = [ @@ -7217,9 +7246,9 @@ rec { }; "wasm-bindgen-macro-support" = rec { crateName = "wasm-bindgen-macro-support"; - version = "0.2.128"; + version = "0.2.129"; edition = "2021"; - sha256 = "12p4jm9dr88h1j5sglycizvvxvr05pgmz7aa2v9g47h7y23lh7j1"; + sha256 = "1dx6w90f14avmhri7ss9lyz03060g6475r4axy3bm7biqf5ill3g"; libName = "wasm_bindgen_macro_support"; authors = [ "The wasm-bindgen Developers" @@ -7253,10 +7282,10 @@ rec { }; "wasm-bindgen-shared" = rec { crateName = "wasm-bindgen-shared"; - version = "0.2.128"; + version = "0.2.129"; edition = "2021"; links = "wasm_bindgen"; - sha256 = "0ghh8hqx038h9c9zj1rlmzlv2k2shh917q756dn054hcipbir541"; + sha256 = "1ilmp5d3sl8lrq9djhcs90gk66yvk8mgwk4sfrvpvkd75b2wkw13"; libName = "wasm_bindgen_shared"; authors = [ "The wasm-bindgen Developers" @@ -7271,9 +7300,9 @@ rec { }; "web-sys" = rec { crateName = "web-sys"; - version = "0.3.105"; + version = "0.3.106"; edition = "2021"; - sha256 = "1y57whpj5ncrl0cljmfsj5m31jqm66ylad68317yq03g0d5drgcz"; + sha256 = "195bd1m14w3hsw5fg3sr938mh7y4cb260d0sq6a6brffxjfin9l8"; libName = "web_sys"; authors = [ "The wasm-bindgen Developers" @@ -8440,9 +8469,9 @@ rec { }; "zerocopy" = rec { crateName = "zerocopy"; - version = "0.8.57"; + version = "0.8.58"; edition = "2021"; - sha256 = "0wr85plmrkz0nh5grbqap23l9ghqq4mq1ip4x759q23dyflh4lfk"; + sha256 = "0i6vsbj4qfqj62b8qxl7wdcin8rnqz6pw88p6zzl5dc2mnpqyzn1"; dependencies = [ { name = "zerocopy-derive"; @@ -8472,9 +8501,9 @@ rec { }; "zerocopy-derive" = rec { crateName = "zerocopy-derive"; - version = "0.8.57"; + version = "0.8.58"; edition = "2021"; - sha256 = "1g5js10p1q1d7wn6b74wjvrjznv34x5cfxpj7jj8s9a4mgsh2v0l"; + sha256 = "1whbf7cjcqyp5nhvma78yy489swr7mfgc9m6r6h4ckyz8kh5cpsr"; procMacro = true; libName = "zerocopy_derive"; dependencies = [ diff --git a/Cargo.toml b/Cargo.toml index 8da5e69f..1a5467a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,11 @@ name = "reshape_observation" harness = false test = false +[[bench]] +name = "scale_bisect" +harness = false +test = false + [dependencies] gp-tree = { path = "crates/gp-tree" } improvise-core = { path = "crates/improvise-core" } diff --git a/README.md b/README.md index 76c15f5a..b4a4b357 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ improvise import examples/demo.csv \ --no-wizard \ --category Region --category Product --category Customer \ --measure Revenue --measure Cost \ - --time Date --extract Date:Month \ + --time Date --extract Date:Month --split-date Date \ --axis Region:row --axis Product:row \ - --axis Date_Month:column --axis Measure:column \ + --axis Date_Month:column --axis _Measure:column \ --axis Customer:page --axis Date:none \ --formula "Profit = Revenue - Cost" \ --name "Acme Sales Demo" \ @@ -102,7 +102,7 @@ rollback target — it is not what the published site is running. Every cell lives at the intersection of named categories and items — `(Region=East, Measure=Revenue)` — not at a grid address like `B3`. A model -can have up to 12 categories, each with an ordered list of items that can be +can have up to 24 categories, each with an ordered list of items that can be organized into collapsible groups (e.g. months grouped into quarters). Formulas reference dimension names, not cell addresses: @@ -178,7 +178,14 @@ opening one fails with an error that says so. JSON *import* is unaffected. The import wizard analyzes CSV columns and proposes each as a category (string-valued), measure (numeric), time dimension (date-like, with optional year/month/quarter extraction), or skip. Multiple CSVs merge automatically -with a synthetic "File" category. +with a synthetic "File" category, even when their headers differ. A record +simply has no value for a column its file lacks. A folder argument imports +every `*.csv` in it, in name order, even when it holds a single CSV. Each file +also gets its own view, filtered to that file and showing only its columns: + +```sh +improvise import reports/ --no-wizard -o reports.improv +``` All model mutations go through a typed command registry, so the same operations that back the TUI work headless: diff --git a/bench/edge.py b/bench/edge.py new file mode 100644 index 00000000..f5e199e5 --- /dev/null +++ b/bench/edge.py @@ -0,0 +1,8 @@ +import sys; sys.path.insert(0,'bench') +from scale_bisect import run +cfgs=[dict(k=k,prune=0) for k in (16,17,18,26,27)]+[dict(k=k,prune=1) for k in (7,11,12,13,14,21,22)]+[dict(k=1000,n=n,mode='records') for n in (1000,1200,1500,1800,2000,4600,14500)] +for c in cfgs: + c.setdefault('n',100000); c['ops']='open,key,window' + v=[run('/tmp/sb',c,timeout=200) for _ in range(3)] + f=lambda k:'/'.join(f"{r[k][1]:.1f}" if k in r else '-' for r in v) + print({k:c[k] for k in c if k!='ops'},'rows',v[0].get('rows'),'open',f('open'),'key',f('keypress_down'),'win',f('browser_grid_of_top'),'tuiframe',f('tui_frame_grid_of_whole'),flush=True) diff --git a/bench/full.py b/bench/full.py new file mode 100644 index 00000000..27d79581 --- /dev/null +++ b/bench/full.py @@ -0,0 +1,10 @@ +import sys, json; sys.path.insert(0,'bench') +from scale_bisect import run +cfgs=[dict(k=9,prune=0),dict(k=10,prune=0),dict(k=17,prune=0,groups=1),dict(k=26,prune=0,groups=1), + dict(k=8,prune=1),dict(k=13,prune=1,groups=1), + dict(k=1000,n=1300,mode='records'),dict(k=1000,n=4400,mode='records'),dict(k=1000,n=14000,mode='records')] +for c in cfgs: + c.setdefault('n',100000) + for rep in range(3): + r=run('/tmp/sb',c,timeout=300) + print(json.dumps({'cfg':c,'rep':rep,**r}),flush=True) diff --git a/bench/scale_bisect.py b/bench/scale_bisect.py new file mode 100644 index 00000000..9d576cfa --- /dev/null +++ b/bench/scale_bisect.py @@ -0,0 +1,38 @@ +"""Drive benches/scale_bisect one configuration per process; parse its lines. +usage: scale_bisect.py BIN k=.. n=.. [cats=..] [prune=..] [mode=..] [ops=..] [--timeout S] +""" +import subprocess, sys, re, json + +def run(binary, cfg, timeout=90): + args = [binary] + [f"{k}={v}" for k, v in cfg.items()] + try: + p = subprocess.run(args, capture_output=True, text=True, timeout=timeout) + out, status = p.stdout, "ok" + except subprocess.TimeoutExpired as e: + out, status = (e.stdout or b"").decode() if isinstance(e.stdout, bytes) else (e.stdout or ""), "timeout" + res = {"status": status} + for line in out.splitlines(): + if line.startswith("REFUSED"): + res["status"] = "refused" + m = re.match(r"(\w+) min=([\d.]+) med=([\d.]+) max=([\d.]+)", line) + if m: + res[m[1]] = (float(m[2]), float(m[3]), float(m[4])) + for kv in re.findall(r"(\w+)=([\d.]+)", line): + if kv[0] in ("rows", "cols", "maxrss_mb", "improv_mb", "snapshot_kb", "rss_after_open_mb", "cells"): + res[kv[0]] = float(kv[1]) + m = re.match(r"json_encode_setcell_updates ms=(.*)", line) + if m: + res["json_setcell"] = m[1] + return res + +if __name__ == "__main__": + binary = sys.argv[1] + cfg, timeout = {}, 90 + rest = sys.argv[2:] + for a in rest: + if a.startswith("--timeout"): + timeout = int(a.split("=")[1]) + else: + k, v = a.split("=", 1) + cfg[k] = v + print(json.dumps(run(binary, cfg, timeout))) diff --git a/bench/sweep.py b/bench/sweep.py new file mode 100644 index 00000000..f0c166d6 --- /dev/null +++ b/bench/sweep.py @@ -0,0 +1,10 @@ +import sys; sys.path.insert(0,'bench') +from scale_bisect import run +import itertools +OPS=sys.argv[1]; +cfgs=eval(sys.argv[2]) +cols=sys.argv[3].split(',') +for c in cfgs: + c=dict(c); c['ops']=OPS + r=run('/tmp/sb',c,timeout=int(sys.argv[4]) if len(sys.argv)>4 else 90) + print(c, r['status'], 'rows',r.get('rows'), *[f"{k}={r[k][1]:.1f}" if k in r else f"{k}=-" for k in cols], f"rss={r.get('maxrss_mb')}", flush=True) diff --git a/benches/grid_projection.rs b/benches/grid_projection.rs index a61a8272..bfb77330 100644 --- a/benches/grid_projection.rs +++ b/benches/grid_projection.rs @@ -84,9 +84,6 @@ fn chain_app(regions: usize, products: usize) -> App { v.set_axis("Region", Axis::Row); v.set_axis("Product", Axis::Row); v.set_axis("_Measure", Axis::Column); - for g in 0..regions.div_ceil(10) { - v.toggle_group_collapse("Region", &format!("G{g}")); - } } let mut n = 0.0f64; for r in 0..regions { @@ -127,9 +124,6 @@ fn grouped_app(days: usize, per_group: usize, products: usize, measures: usize) v.set_axis("Day", Axis::Row); v.set_axis("Product", Axis::Row); v.set_axis("_Measure", Axis::Column); - for g in 0..days.div_ceil(per_group) { - v.toggle_group_collapse("Day", &format!("G{g}")); - } } let mut n = 0.0f64; for d in 0..days { diff --git a/benches/scale_bisect.rs b/benches/scale_bisect.rs new file mode 100644 index 00000000..cc58e0a7 --- /dev/null +++ b/benches/scale_bisect.rs @@ -0,0 +1,347 @@ +//! One configuration per process: build a sparse document, time the real paths. +//! `scale_bisect k=1000 n=100000 cats=3 prune=1 mode=grid`; see context/test-evidence/scale-bisect.md. + +use std::collections::{HashMap, HashSet}; +use std::io::Write; +use std::time::Instant; + +use improvise::model::cell::{CellKey, CellValue}; +use improvise::view::Axis; +use improvise::workbook::Workbook; +use improvise_command::ui::app::App; +use improvise_command::ui::effect::{self, ViewTarget}; +use improvise_command::ui::host::Host; +use improvise_command::ui::project::{self, Viewport}; +use improvise_command::ui::reduce::caught_up; +use improvise_io::persistence::{format_md, parse_md, save_to}; +use improvise_io::storage::MemoryFs; +use improvise_protocol::{ + ClientFrame, KeyCode, KeyEvent, KeyModifiers, ModelCommand, RecordHash, render::RecordCell, +}; + +fn lcg(s: &mut u64, n: usize) -> usize { + *s = s + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (*s >> 33) as usize % n +} + +struct Cfg { + k: usize, + n: usize, + cats: usize, + prune: bool, + mode: String, + groups: bool, + ops: String, +} + +fn arg(args: &HashMap, k: &str, d: &str) -> String { + args.get(k).cloned().unwrap_or_else(|| d.to_string()) +} + +fn document(c: &Cfg) -> (Workbook, Vec) { + let mut wb = Workbook::new("bench"); + for ci in 0..c.cats { + let cat = format!("C{ci}"); + wb.add_category(cat.clone()).expect("under the limit"); + for i in 0..c.k { + if c.groups && ci == 0 { + wb.add_item_in_group(&cat, format!("i{i}"), format!("G{}", i / 10)) + .expect("added"); + } else { + wb.add_item(&cat, format!("i{i}")).expect("added"); + } + } + } + for m in ["Cost", "Rev"] { + wb.add_item("_Measure", m).expect("virtual"); + } + let space = (c.k as f64).powi(c.cats as i32); + let wanted = c.n.min((space / 2.0).max(1.0) as usize); + let (mut s, mut seen, mut recs) = (0x9E37_79B9_7F4A_7C15u64, HashSet::new(), Vec::new()); + while seen.len() < wanted { + let d: Vec = (0..c.cats).map(|_| lcg(&mut s, c.k)).collect(); + if !seen.insert(d.clone()) { + continue; + } + let coords: Vec<(String, String)> = d + .iter() + .enumerate() + .map(|(ci, x)| (format!("C{ci}"), format!("i{x}"))) + .collect(); + recs.push(CellKey::new(coords.clone())); + for (m, v) in [("Cost", 3.0), ("Rev", 5.0)] { + let mut key = coords.clone(); + key.push(("_Measure".to_string(), m.to_string())); + wb.model.set_cell(CellKey::new(key), CellValue::Number(v)); + } + } + let view = wb.active_view_mut(); + for ci in 0..c.cats { + let axis = if c.mode == "records" || c.mode == "none" { + Axis::None + } else { + Axis::Row + }; + view.set_axis(&format!("C{ci}"), axis); + } + if c.mode == "records" { + view.set_axis("_Index", Axis::Row); + view.set_axis("_Dim", Axis::Column); + } else { + view.set_axis("_Measure", Axis::Column); + } + view.prune_empty = c.prune; + (wb, recs) +} + +fn maxrss_mb() -> f64 { + let mut u: libc::rusage = unsafe { std::mem::zeroed() }; + unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut u) }; + u.ru_maxrss as f64 / 1048576.0 +} + +fn rss_mb() -> f64 { + let out = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &std::process::id().to_string()]) + .output() + .expect("ps"); + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .unwrap_or(0.0) + / 1024.0 +} + +/// Sample `f` up to `reps` times, stopping once `budget_ms` is spent (one sample minimum). +fn sample(name: &str, reps: usize, budget_ms: f64, mut f: impl FnMut(usize)) { + let mut v = Vec::new(); + let start = Instant::now(); + for r in 0..reps { + let t = Instant::now(); + f(r); + v.push(t.elapsed().as_secs_f64() * 1000.0); + if start.elapsed().as_secs_f64() * 1000.0 > budget_ms { + break; + } + } + v.sort_by(|a, b| a.partial_cmp(b).expect("finite")); + println!( + "{name} min={:.3} med={:.3} max={:.3} reps={}", + v[0], + v[v.len() / 2], + v[v.len() - 1], + v.len() + ); + std::io::stdout().flush().ok(); +} + +fn once(name: &str, f: impl FnOnce() -> T) -> T { + let t = Instant::now(); + let v = f(); + println!( + "{name} min={0:.3} med={0:.3} max={0:.3} reps=1", + t.elapsed().as_secs_f64() * 1000.0 + ); + std::io::stdout().flush().ok(); + v +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +fn main() { + let args: HashMap = std::env::args() + .skip(1) + .filter_map(|a| { + a.split_once('=') + .map(|(k, v)| (k.to_string(), v.to_string())) + }) + .collect(); + let c = Cfg { + k: arg(&args, "k", "1000").parse().expect("k"), + n: arg(&args, "n", "100000").parse().expect("n"), + cats: arg(&args, "cats", "3").parse().expect("cats"), + prune: arg(&args, "prune", "1") == "1", + mode: arg(&args, "mode", "grid"), + groups: arg(&args, "groups", "0") == "1", + ops: arg( + &args, + "ops", + "open,key,frame,window,setcell,host,reshape,io", + ), + }; + let rows_cap: f64 = arg(&args, "rows_cap", "30000000").parse().expect("cap"); + let product = (c.k as f64).powi(c.cats as i32); + if c.mode == "grid" && product > rows_cap { + println!( + "REFUSED product_rows={product:.3e} (> {rows_cap:.0}: layout would materialize it)" + ); + return; + } + let want = |o: &str| c.ops.split(',').any(|x| x == o); + let (wb, recs) = document(&c); + println!( + "cells={} records={} base_rss_mb={:.0}", + wb.model.data.iter_cells().count(), + recs.len(), + rss_mb() + ); + let mut app = once("open", || App::new(wb, None)); + println!( + "rows={} cols={} rss_after_open_mb={:.0}", + app.layout.row_count(), + app.layout.col_count(), + rss_mb() + ); + app.term_width = 200; + app.term_height = 50; + let name = app.view_name().to_string(); + + if want("key") { + sample("keypress_down", 8, 6000.0, |_| { + app.dispatch_key(key(KeyCode::Down)); + }); + } + if want("frame") { + let whole = |app: &App| Viewport { + row_offset: 0, + col_offset: 0, + rows: app.layout.row_count(), + cols: app.layout.col_count(), + }; + sample("tui_frame_grid_of_whole", 3, 8000.0, |_| { + std::hint::black_box(project::grid_of(&app, whole(&app))); + }); + let cache = project::grid_of(&app, whole(&app)); + let area = ratatui::layout::Rect::new(0, 0, 200, 50); + sample("tui_render_gridwidget_buffer", 5, 4000.0, |_| { + let mut buf = ratatui::buffer::Buffer::empty(area); + let w = improvise::ui::grid::GridWidget::new( + &cache, + app.cursor(), + &app.view_state.active_view, + &app.layout, + &app.view_state.mode, + &app.view_state.search_query, + &app.view_state.buffers, + app.drill_state.as_ref(), + ); + ratatui::widgets::Widget::render(w, area, &mut buf); + std::hint::black_box(buf); + }); + } + if want("window") { + let mid = app.layout.row_count() / 2; + for (label, off) in [("top", 0), ("mid", mid)] { + let vp = Viewport { + row_offset: off, + col_offset: 0, + rows: 45, + cols: 12, + }; + sample(&format!("browser_grid_of_{label}"), 5, 6000.0, |_| { + std::hint::black_box(project::grid_of(&app, vp)); + }); + } + } + if want("setcell") && !recs.is_empty() { + let existing = recs[recs.len() / 2].clone().with("_Measure", "Rev"); + sample("setcell_existing", 5, 6000.0, |r| { + app.apply_effects(vec![Box::new(effect::SetCell( + existing.clone(), + CellValue::Number(10.0 + r as f64), + ))]); + }); + let mut s = 99u64; + sample("setcell_new_record", 5, 6000.0, |_| { + let mut coords: Vec<(String, String)> = (0..c.cats) + .map(|ci| (format!("C{ci}"), format!("i{}", lcg(&mut s, c.k)))) + .collect(); + coords.push(("_Measure".into(), "Rev".into())); + app.apply_effects(vec![Box::new(effect::SetCell( + CellKey::new(coords), + CellValue::Number(1.0), + ))]); + }); + } + if want("reshape") && c.mode == "grid" { + let v = || ViewTarget::Active; + sample("reshape_hide_item", 3, 6000.0, |r| { + app.apply_effects(vec![Box::new(effect::HideItem { + view: v(), + category: "C1".into(), + item: format!("i{r}"), + })]); + }); + if c.groups { + sample("reshape_toggle_group", 3, 6000.0, |_| { + app.apply_effects(vec![Box::new(effect::ToggleGroup { + view: v(), + category: "C0".into(), + group: "G0".into(), + })]); + }); + } + let last = format!("C{}", c.cats - 1); + sample("reshape_set_axis", 4, 6000.0, |r| { + let axis = if r % 2 == 0 { Axis::Page } else { Axis::Row }; + app.apply_effects(vec![Box::new(effect::SetAxis { + view: v(), + category: last.clone(), + axis, + })]); + }); + sample("reshape_toggle_prune", 2, 6000.0, |_| { + app.apply_effects(vec![Box::new(effect::TogglePruneEmpty(v()))]); + }); + } + if want("io") { + let wbr = &app.model_state.workbook; + let text = once("format_md", || format_md(wbr)); + println!("improv_mb={:.2}", text.len() as f64 / 1048576.0); + once("parse_md", || parse_md(&text).expect("parses")); + let fs = MemoryFs::new(); + sample("persist_per_command_save_to", 3, 6000.0, |_| { + save_to(&fs, wbr, std::path::Path::new("d.improv")).expect("write"); + }); + } + if want("host") && !recs.is_empty() { + let mut host = Host::new(app); + let (id, snap) = once("host_connect_snapshot", || host.connect(45, 12)); + sample("json_encode_snapshot", 3, 4000.0, |_| { + std::hint::black_box(serde_json::to_vec(&snap.msg).expect("json")); + }); + println!( + "snapshot_kb={:.1}", + serde_json::to_vec(&snap.msg).expect("json").len() as f64 / 1024.0 + ); + let rec = recs[recs.len() / 2].clone(); + let cell = RecordCell { + record: RecordHash::of(&rec), + measure: Some("Rev".into()), + }; + let mut enc: Vec<(f64, usize)> = Vec::new(); + sample("host_handle_setcell_and_project", 4, 8000.0, |r| { + let wire = caught_up( + host.app(), + ModelCommand::SetCell { + view: name.clone(), + cell: cell.clone(), + value: CellValue::Number(50.0 + r as f64), + }, + ); + let out = host.handle(id, ClientFrame::command(wire)); + let t = Instant::now(); + let bytes: usize = out + .iter() + .map(|a| serde_json::to_vec(&a.msg).expect("json").len()) + .sum(); + enc.push((t.elapsed().as_secs_f64() * 1000.0, bytes)); + }); + println!("json_encode_setcell_updates ms={:?}", enc); + } + println!("maxrss_mb={:.0}", maxrss_mb()); +} diff --git a/context/cloud-agents.md b/context/cloud-agents.md index 9fb343b4..fa6bf2f1 100644 --- a/context/cloud-agents.md +++ b/context/cloud-agents.md @@ -1,11 +1,30 @@ # Cloud Agents (LLM Reference) -Environment notes for agents running in Claude Code on the web. Local -checkouts are unaffected — the session hook exits early unless +Environment notes for agents running in cloud containers — Claude Code on the web, +Cursor cloud agents, and similar setups. Local checkouts are unaffected. + +## Session Bootstrap + +All agent stacks use the same bootstrap script: `scripts/agent-bootstrap.sh`. It: + +1. **Seeds nix flake inputs** (GitHub tarballs are blocked; seeds over git instead) +2. Verifies the `bd` version matches the flake.nix pin +3. Installs `bd` if missing +4. Syncs the Dolt issue tracker from the git remote +5. Warms the cargo build **inside `nix develop`** + +**After bootstrap, `nix develop` works.** This is the required toolchain — bare system +cargo that fails `edition2024` or missing wasm target is a misconfigured session. + +**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically when `CLAUDE_CODE_REMOTE=true`. -`.claude/hooks/session-start.sh` handles the setup automatically. This doc -is the "why", and the record of what bites when it doesn't run. +**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are +in `.cursor/skills/` (symlinks to `.agents/skills/`). + +**Other stacks**: Run `scripts/agent-bootstrap.sh` manually after checkout. + +This doc is the "why", and the record of what bites when setup doesn't run. --- @@ -60,7 +79,8 @@ same if you need to re-sync by hand; never run it in the working tree. | Thing | Status | Use instead | |---|---|---| -| `nix develop` **unseeded** | Fails: flake inputs are GitHub **tarballs**, which are not served here | Run `scripts/nix-seed-flake-inputs.sh` first — see below. Or just use the preinstalled `cargo`/`rustc`, the same stable toolchain the flake pins | +| `nix develop` **without bootstrap** | Fails: flake inputs are GitHub **tarballs**, which are blocked | Bootstrap seeds inputs automatically; if it failed, run `scripts/nix-seed-flake-inputs.sh` manually | +| Bare system cargo | Fails: `edition2024`, missing wasm target | **Always use `nix develop`** — this is the required toolchain | | `bd dolt push` | Fails: 403 outbound | Nothing; bead status changes stay local. Say so rather than implying the tracker was updated | | `git+ssh://` Dolt remote in `config.yaml` | No ssh client in the container | `git+https://`, which the hook passes explicitly | | `git push`, GitHub API | **Work normally** | — | @@ -70,13 +90,13 @@ starting by hand). Reads of public GitHub repos over the git protocol work — it is specifically the `/archive/*.tar.gz` and `codeload` endpoints that are refused. -### Making `nix develop` work +### How nix develop works after bootstrap -`scripts/nix-seed-flake-inputs.sh` makes the devShell work **without touching -`flake.nix` or `flake.lock`**. Verified end to end: `rustc 1.98.1`, `cargo`, -`crate2nix` in the shell, `git status` clean. +The bootstrap runs `scripts/nix-seed-flake-inputs.sh` automatically, which seeds +the flake inputs over git **without touching `flake.nix` or `flake.lock`**. +After bootstrap, `nix develop` should work. If it fails, check `/tmp/nix-seed.log`. -Why it is needed, and why the obvious fixes are not available: +Why the seeding is needed: - Nix's `github:` fetcher wants a tarball. GitHub traffic **bypasses the environment's domain allowlist entirely** and goes through a proxy that @@ -105,9 +125,9 @@ around it — a mismatch fails loudly. Do not instead rewrite the lock's nodes to `git` type: carrying the old `narHash` onto a different fetcher is exactly the claim the lock exists to make. -It is not in the session-start hook because it is expensive (minutes, plus -several GB of store once the devShell builds) and most work does not need the -devShell. Run it when you do. +The bootstrap runs this automatically. It takes a few minutes and several GB of +store once the devShell builds. If bootstrap reported a seed failure, check +`/tmp/nix-seed.log` and re-run `scripts/nix-seed-flake-inputs.sh` manually. Two alternatives if per-container seeding is unattractive: push the inputs to a binary cache with `nix flake archive` and point sessions at it with @@ -143,3 +163,52 @@ environment, where egress leaves through your own network boundary. reorder or fold commits (`.agents/skills/wave/agent-rules.md`, rule 8). - Keep `context/repo-map.md` and `context/design-principles.md` current — they are the two docs agents are expected to maintain by hand. + +--- + +## PR Tracking + +For any PR you own, **track both CI checks and GitHub comments/reviews**: + +- **`scripts/pr-watch `**: Waits for checks, then prints all reviews/comments (default). + Reviews are a separate API surface — Codex/ChatGPT reviews arrive as *reviews* not comments. +- **Answer feedback with attribution** before marking work done (see below). +- **Re-check after push** that reviews target the current head SHA. + +The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe. +See AGENTS.md "PR Tracking: Checks AND Reviews" and "Shipping a wave" for the full workflow. + +--- + +## GitHub Attribution (Hard Rule) + +**Every GitHub write (PR, comment, review reply) must include an attribution line.** +Posts go out under the repository owner's account — an unattributed post reads as the +human's words. + +Open the body with: `_Posted by ()_` + +Examples: +- `_Posted by Claude (Opus 4.5)_` +- `_Posted by Cursor Agent (claude-sonnet-5)_` + +A thread is not "answered" until the reply carries this attribution. See AGENTS.md +"GitHub Attribution" for the full rule. + +--- + +## Knowledge Base + +**Use `scripts/kb`, not Prolog MCP.** The Prolog MCP is not available in all agent stacks +(Cursor cloud agents do not have it). All agents must use `scripts/kb` for KB operations: + +```bash +scripts/kb check # kb_consistent, exactly what CI runs +scripts/kb query '' # run a goal; exit 1 if it fails +scripts/kb add # add clauses; refuses if a key already exists +scripts/kb replace # add, overwriting existing keys +scripts/kb rm # remove by key; refuses if a key is absent +``` + +Reading `prolog-sessions/kb.pl` directly for queries is fine. See the `kb` skill at +`.agents/skills/kb/SKILL.md` for the full interface. diff --git a/context/code/cli.md b/context/code/cli.md index 5b5ab034..39a32083 100644 --- a/context/code/cli.md +++ b/context/code/cli.md @@ -369,3 +369,172 @@ non-zero and writes nothing. It is checked before the build rather than read off the build's error, so the listing is structured data and not a parsed sentence. `separate` uses `ImportPipeline::suggested_duplicate_category`, and there is deliberately no flag to name the category yet. + +## 12 — `import` takes folders, and the CLI only says which arguments are folders (improvise-lpzu) + +`improvise import` takes any mix of files and folders. A folder stands for its +`*.csv` files, in name order. A folder always merges, even when it holds a +single CSV, so that file keeps its `File` item and view. A single file named +directly still imports alone (the user's ruling on the PR). The rules +and their reasons are in `context/code/formula-and-io.md`, "A folder of CSVs is +one sparse model, with a view per file". The CLI's part is the one question +`Storage` cannot answer: whether a path is a folder. `get_import_data` asks +`Path::is_dir` for each argument, hands the resulting `ImportPath`s to +`expand_import_paths` over `PhysicalFs`, and from there takes the path a list +of files always took. A folder with no CSV in it is refused before anything is +read. The message names the folder, and the run exits non-zero and writes +nothing. + +A merge now reaches the build as an `ImportPipeline` carrying the merged files' +`sources`, rather than as a bare JSON value. Both `--no-wizard` and the TUI +wizard (`run_tui` → `App::start_import`) therefore build the per-file views. +The headless path matters most, because the code-maat pipeline +(improvise-1xfb.5.1) runs there. `a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte` +pins the views, the sparse Data lines and the byte-identical second run, with +the first run's output sitting in the folder it reads. + +## 13 — a headless import refuses rather than saving less than was asked (improvise-8jk6.4, 8jk6.5, 8jk6.6) + +The user treats the headless command and model layer as a product of its own, +and the scripted `import --no-wizard` is where it meets a pipeline. The +repo-stats pipeline (improvise-1xfb.5.1) found three ways that import saved +something other than what the flags asked for, and still exited 0. The +reasoning for each is in `context/code/formula-and-io.md`, "A CSV field is +text, and the field's role reads it" and "An import refuses what it cannot +keep". The CLI's contract: + +- **`--category` keeps the CSV text.** A forced category's items are the + file's text, verbatim: `00565796` stays `00565796`, and `220944e2` stays + `220944e2`. Measures still read as numbers. A JSON number has no source + text, so it is named as JSON writes it (`1e3` is `1000.0`). +- **An unparseable `--formula` is a refusal.** It exits non-zero and writes + nothing. Every bad formula is named on stderr, each with its parse error. + This is §10's rule: the request was not met, so the exit code says so. The + wizard refuses the same text at the formula step, with the same wording. +- **An import that kept no record is a refusal.** It exits non-zero, writes + nothing, and says why: every record was skipped as not a JSON object, or + the source had none. +- **An import that skipped *some* records exits 0 by default.** It writes the + model and warns with a count. With `--strict` (the user's ruling on PR #321) + any skipped record is a refusal instead: exit 1, the same count named, and + nothing written. The argument is in `formula-and-io.md`. + +Two refusals bail in `run_headless_import` before the build: the `--strict` +skip check (`src/main.rs:305`) and the `--on-duplicate refuse` collision +check. The rest come from `build_model_with_warnings` as an `Err`. All of +them reach `main` as exit 1 before `persistence::save`. So "writes nothing" +holds by statement order, not by where the refusal is raised. The headless +tests check it by asserting that the output path does not exist. + +## 14 — the command language reads `|pipe quoting|`, and a surplus argument is refused (improvise-31oe.3) + +`create-view |By area|` used to make a view named `|By` and drop `area|` without +a word. Two separate defects, fixed separately. + +**The quoting.** `parse.rs` now has one lexer, `lex`, and everything else is +built on it: `parse_line_with`, `segments` and `parse_segment_interactive`. The +rules are: + +- A `|…|` piece is read by `improvise_formula::quote::unescape_pipe_body`, the + one home of pipe quoting (gotcha 34). So `\|`, `\\` and `\n` mean exactly what + they mean in `.improv` and in formulas. The lexer only finds the closing pipe: + a backslash takes the next character with it, so `\|` does not close. +- A piece may sit **anywhere in a word**, and the pieces and bare text are + joined. So a coordinate quotes each half, `|Sales Region|/|East Coast|`, the + way a `.improv` data line quotes each name. The alternative, a pipe only at + the start of a word, would have left no way to spell a coordinate with a + space in it. +- `"…"` is **kept**, verbatim, with no escapes, and a `|` inside it stays a + `|`. It is not a workaround to retire. Formula text carries its own pipes: + `add-formula "Net = |Gross Rev| - Cost"` must reach `parse_formula` with the + pipes on. If the line had pipes only, every pipe in a formula would need + escaping. Its old leniency is kept too: a `"` that is never closed runs to + the end of the line. A `|` that is never closed is refused, naming the text + from the open pipe on, because a name is never meant to run to the end. +- The separator is a **bare** `.` word. A quoted `|.|` or `"."` is an argument, + and a `.` inside `|a . b|` does not split the line. The old `split_on_dot` + was a second scanner that knew double quotes and not pipes. With one lexer + there is no second scanner to disagree. +- A comment line (`#`, `//`) is not lexed, so an unmatched pipe in a comment is + not an error. + +`segments` now returns `Result`, because an unterminated pipe is a fact about +the whole line. `main.rs` refuses the line before running any of it, as §7 does +for a parse error, and `ExecuteCommand` refuses it on the status line. + +What is still not spellable: an item whose name contains `/`. The coordinate is +split on the first `/` **after** unquoting, so `|a/b|/x` is still read as +category `a` and item `b/x`. Closing that needs the lexer to hand structured +coordinates to the commands, which is a larger change than this ticket. + +**The arity.** Every registration now declares `MaxArgs::AtMost(n)` or +`MaxArgs::Any` beside its prototype. `CmdRegistry::parse` and +`CmdRegistry::interactive` apply it after construction succeeds. So the rule is +written once and every command gets it, and a new registration cannot compile +without a declaration. The refusal names the command, the limit and every +surplus argument (`create-view: takes at most 1 argument(s), got 2; unexpected +"area"`). A headless run exits non-zero through the §7 parse gate, and the `:` +line refuses on the status line. + +It runs **after** the command's own parse, deliberately. A command whose own +refusal states its shape keeps it: `add-formula`'s "quote the formula" +(improvise-z05h) is more use than an arity, and it is what `formula Product +Total = Widget + Gadget` still says. + +`MaxArgs::Any` is a claim, and the list of commands making it is short: +`add-items`, `set-cell`, `set-format` (it joins its words), `clear-cell`, +`yank`, `paste`, `drill-into-cell` and `commit-cell-edit`. Each takes a list of +items or coordinates. `no_command_silently_drops_an_argument_it_was_given` names +that list, so widening another command to `Any` has to add it there. + +## 15 — a command that names something the document lacks is refused (improvise-31oe.2) + +`set-axis Regoin row`, `set-page Region Nowhere` and `set-axis Region sideways` +all exited 0 and said nothing. §10's rule applies: nothing changed, so the +request was not met. + +- **An unreadable axis is an argument error.** `set-axis` now checks its axis + with `parse_axis` when it is parsed, so a script is refused before any of it + runs. `execute` still refuses an axis it cannot read rather than returning + nothing. That arm cannot be reached through the registry. +- **An unknown category, item, group or view is a refusal**, from `RequireKnown` + (below), because only the document knows what it holds. `effect::unknown_name` + asks the active view (`try_axis_of`) for a category. That is exactly the + condition under which `View::set_axis` was a silent no-op. It asks + `effective_item_names` for an item, so a formula target is a known `_Measure` + item. It asks both an item's own group and the category's `groups` for a + group. `add_item_in_group` registers no `Group`, and a parent group has no + items, so either one alone misses a real group. A view is asked of + `workbook.views`. + +The siblings fixed with it: `hide-item` and `show-item` (hiding an unknown item +wrote it into `hidden_items`), `toggle-group`, and `switch-view` (the effect +ignores a view it lacks, and says nothing). `set-page` with an unknown item was +worse than a no-op: it wrote the name into `page_selections`. + +The check runs at **apply** time, as an effect of its own. `execute` runs +against one context built before the line ran, and a `:` line builds every +segment up front, so a check in `execute` refused +`create-view Second . switch-view Second` and +`add-item Type Tools . set-page Type Tools` after their first halves had landed, +while `improvise cmd` (which rebuilds the context per segment) accepted them. +Each of these commands therefore returns `RequireKnown(needs)` followed by the +real effect. `RequireKnown` refuses and aborts the batch (gotcha 21), so the +real effect never applies. It is a `ViewOnly` effect that writes nothing and is +off the wire on purpose: the wire's own effects (`model_effects`, gotcha 38) +stay non-refusing, since a refusing effect there becomes a frame rejection +(`MutationRefused`). That is arguably right, but it changes the protocol, and +a browser composes these only from names it was sent. It is left for a ticket +of its own. + +A refused name stops the rest of a `:` line, because the abort ends the one batch +the whole line runs as. Headless runs each segment as its own batch, so an abort +never crosses a `.` there: `:set-page Type Nowhere . add-item Type X` adds +nothing, while `improvise cmd` on the same line exits 1 and still adds `X`. +`load`/`Save` already behaved this way. The two front ends' chain semantics are +left for a ticket of their own. + +The interactive cursor fallbacks of `clear-cell`, `yank`, `paste` and +`drill-into-cell` answer only for coordinates that were OMITTED +(`only_when_omitted`). Coordinates that were given and are malformed are +refused, never replaced by the cursor's cell. diff --git a/context/code/clients.md b/context/code/clients.md index 2c6c33f2..5a359f1f 100644 --- a/context/code/clients.md +++ b/context/code/clients.md @@ -831,7 +831,7 @@ The classes that remain: column renumbers every index to its right and the advance was computed from the pre-commit layout (gotcha 23), so `CommitAndAdvance` returns straight after it and a client that advanced anyway would land one column off, - silently. `AddCategory` can also be refused by the 12-category limit and sets + silently. `AddCategory` can also be refused by the category limit and sets `abort_effects`; the mode change therefore goes **first**, where an abort cannot reach it (gotcha 21). **Since improvise-bd9o that exception is unreachable**, and this bullet describes `commit` alone: the records decline @@ -1652,7 +1652,7 @@ through the server's own types; with one definition there is nothing left for them to compare, which is what improvise-xks6 was for. **`rejection_message` is spelled here rather than on `Rejection`** for the -reason `AxisColor` is not a ratatui colour: the protocol carries the *fact*, and +reason a `Face` is not a ratatui colour: the protocol carries the *fact*, and each front end words it. The wording tracks ADR 001's force table, because "try again" and "this cannot be forced" are the two different things a user must be able to tell apart — a `RecordGone` has nothing to write to, and offering force @@ -1877,8 +1877,8 @@ Four smaller decisions that would be re-derived wrongly: `improvise_protocol::read_buffer` so the `"search"` special case keeps its one home (gotcha 11); `None` is "no text-entry mode is open", so the page asks nothing about the mode. The colour stays semantic — `MinibufColor` crosses and - the CSS class is the page's last translation, exactly as `draw.rs::minibuf_fg` - is the TUI's (gotcha 26). It is drawn as a surface of its own rather than *in + the CSS class is the page's last translation. (The TUI no longer reads it: it + draws a face per text-entry mode, and improvise-nn4s.4 moves the page there.) It is drawn as a surface of its own rather than *in place of* `#status` as the TUI's bottom bar does, because `#status` also carries the transport's failures and a repaint must not erase one (gotcha 48). Since improvise-n6t8 it shares `#bottom-dock` with the toast, which stacks @@ -1920,7 +1920,7 @@ home for it. The rows themselves are `improvise_protocol::help::pages()`, the same ones `src/ui/help.rs` draws. `RenderHelpRow` carries a `kind` string (`heading`, `keys`, `accent`, …) which is a *semantic* name the page maps to a -CSS class — the `AxisColor → axis_fg` shape again, and the reason the page can +CSS class — the `Face → draw.rs::face_style` shape again, and the reason the page can style a heading without knowing what makes a line a heading. Where the content comes from and what is derived from the keymap is `context/code/protocol.md` §8. @@ -2353,7 +2353,7 @@ truncation. A browser has no column to fit, so the abbreviation buys nothing and reads to a user as a rendering defect — it was spotted in PR #120's screenshots as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other vocabulary already on `Axis`: the decision stays in the shared layer and only -the *choice* of vocabulary is local, exactly the `AxisColor → ratatui::Color` +the *choice* of vocabulary is local, exactly the `Face → ratatui::Style` shape §5 blesses. `short_label` is untouched, because the TUI's width is load-bearing. @@ -2439,8 +2439,8 @@ So it is now a raised bar: fixed, centred over the grid, larger, bordered in the mode's own colour and shadowed. **Every part of that is paint.** The content is `RenderMinibuffer`, built in `render.rs` from `state.view.mode.minibuffer()` — the same semantic value the TUI reads — and the colour is `MinibufColor` -resolved to a concrete colour exactly as `draw.rs::minibuf_fg` resolves it for -the terminal. Nothing here asks which mode is open, which buffer is being typed +resolved to a concrete colour by `style.css`. (The TUI now draws a face per +text-entry mode instead; improvise-nn4s.4 moves the page onto the same faces.) Nothing here asks which mode is open, which buffer is being typed into, or what the prompt says; a second answer to any of those is the disease design-principles §5 is about. The caret is drawn as a CSS `::after` rather than appended to the text for the same reason: the text is the host's, and the page diff --git a/context/code/command.md b/context/code/command.md index a89a6621..843d0f0d 100644 --- a/context/code/command.md +++ b/context/code/command.md @@ -2996,8 +2996,10 @@ ride in `ViewState::transient_prefix`, so they must live where the wire types do and the re-export keeps `crate::command::key::…` paths working. `parse.rs` is the quasi-lisp prefix parser: `word arg1 arg2 …`, several commands per -line separated by `.`, coordinate pairs written `Category/Item`, and quoted strings -(`"Profit = Revenue - Cost"`). +line separated by `.`, coordinate pairs written `Category/Item`, verbatim quoted +strings (`"Profit = Revenue - Cost"`) and `|pipe-quoted names|` read by +`improvise_formula::quote`. One lexer serves every entry point, so the separator +and the quoting cannot disagree (improvise-31oe.3, `context/code/cli.md` §14). ### `cmd/core.rs` @@ -3014,6 +3016,12 @@ writes any other field, so an accepted argument list is never overridden by context (improvise-2bac, improvise-yf7p; the argument is `context/code/cli.md` §8). `register_pure` is the case with no fallback: both arms are `parse`. +Every registration also declares its `MaxArgs`, and the registry refuses surplus +arguments on both arms after the command is built. `register_nullary` declares +`AtMost(0)` for itself, and an `effect_cmd!` declares its limit in the macro +call as `MAX_ARGS`. So the arity rule is written once and is total over the +registry (improvise-31oe.3, `context/code/cli.md` §14). + `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 map but its own `ViewState` field (improvise-gdl, gotcha 11). That knowledge used to @@ -3372,6 +3380,14 @@ The parseable model-mutation commands are thin `Cmd` wrappers around effects, constructible from string args, sharing the same execution path as keymap-dispatched commands (gotcha 16). +A wrapper that names a category, item, group or view returns +`effect::RequireKnown(Needs)` followed by its real effect (improvise-31oe.2). +`RequireKnown` asks `unknown_name` against the live app when the batch applies +and refuses and aborts if the document lacks the name, so a `:` line sees what +its earlier segments made. The effects themselves stay silent about unknown +names, because they are also the wire's mutation path. Why the check is a +separate effect: `context/code/cli.md` §15. + ### `cmd/wizard.rs` (improvise-2wm, improvise-907) Import-wizard key handling. The wizard is a small step machine, and which key means diff --git a/context/code/core.md b/context/code/core.md index 297679fa..d17f45df 100644 --- a/context/code/core.md +++ b/context/code/core.md @@ -258,9 +258,11 @@ user drilled in to see all the raw data. On an axis with two grouped categories the runs **nest**: `North` in `Upper` holds `Jan` in `Q1`, and after `Q1` ends `North/Mar` is still in `Upper`. So a -marker names its level. `GroupHeader { level, .. }` opens a run at `level` and -closes every deeper one; `GroupEnd { level }` closes `level` and every deeper -one. `expand_category` reads the level off its prefix, which holds one item per +marker names its level. `GroupHeader { level, depth, .. }` opens a run at +`(level, depth)` and closes every deeper one; `GroupEnd { level, depth }` closes +that run and every deeper one. "Deeper" is the ordering of the pair: the level +first, then the depth inside one category's hierarchy (§19, improvise-felo.6). +`expand_category` reads the level off its prefix, which holds one item per outer category, so it is derived rather than passed. A row's group, for `row_group_for`/`col_group_for` and so for `z`, is the **innermost** run open at it. That is the user's ruling on the ticket: membership is per level, and @@ -357,12 +359,14 @@ browser symptom and silently break drill. `Axis` also owns four presentation vocabularies — `plain_label()`, `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", **not** a truncation of "None") and `initial()` -(one letter for the view panel; `None` is empty) — plus `color() -> AxisColor`. -`AxisColor` is a *semantic* palette because `improvise-core` must not name a -ratatui type; `draw.rs::axis_fg` does the one mapping, exactly as `minibuf_fg` -does for `MinibufColor` (improvise-rml). The tile bar and the category panel each -carried a copy of the colour table before, with nothing keeping them in -agreement. +(one letter for the view panel; `None` is empty). Its **colour is no longer +here**. It was `color() -> AxisColor` (improvise-rml), a semantic palette that +lived in core only because it was a method on a core type; ADR 004 clause 6 +moved it to `improvise-protocol` as `Face::Axis(axis)`, because a face is UI and +this crate is the one that must have none. The TUI resolves it through +`draw.rs::face_style` (`context/code/tui.md`). The tile bar and the category +panel each carried a copy of the colour table before improvise-rml, with nothing +keeping them in agreement, and they still share one: the face. ### `on_category_added`: a virtual on an axis is a configuration, not a vacancy @@ -518,12 +522,129 @@ explicitly would be redundant in the file format and confusing in the UI. `Label` is a high-cardinality per-row field (description, id, note): stored alongside the data so it shows up in record/drill views, but defaulting to -`Axis::None` and excluded from the `MAX_CATEGORIES = 12` pivot limit and from +`Axis::None` and excluded from the `MAX_CATEGORIES` pivot limit and from auto Row/Column assignment. `Category::add_item` deduplicates by name and assigns IDs from a private counter, so external code cannot fabricate an `ItemId`. +### The limit is 24, and nothing is exponential in it (improvise-felo.1) + +`MAX_CATEGORIES` counts **regular** categories only. It is `pub` in +`model/types.rs` and re-exported as `improvise_core::model::MAX_CATEGORIES`, +and it has one home: the import wizard's precondition reads it rather than +keeping a copy. It had a copy until felo.1, a private `const` of its own, and +raising the model's number alone would have left the wizard refusing a 13th +category the model accepts. Tests that fill to the cap derive it from the +constant; `add_category_max_limit` is the one place the number 24 is spelled. + +**Why 12.** Nothing recorded says. The limit arrived in the initial commit +with no comment, ADR or ticket behind it, and nothing in `context/` or `kb.pl` +argues for it. The likeliest source is Lotus Improv, which this tool follows +by name (`context/plan.md`) and whose worksheets are remembered as capped at +12 categories. That is not verified here. It was never a cost bound. The user raised it on 2026-09-25: "12 categories is +feeling small, we should increase to 24". An E*Trade import hit exactly 12, +leaving no room for improvise-cgqt's disambiguating category. + +**What scales with the number of categories**, read off the code: + +- `CellKey` is a sorted `Vec` of pairs: building one is O(k log k), and + comparing or hashing one is O(k). +- The `DataStore` secondary index holds one `(category, item)` entry per + coordinate per cell. That is O(k) per insert and O(cells × k) in memory. +- Records mode has one column per regular category, so it is linear in k. +- `cross_product` is the product of the item counts of the categories **on + an axis**. That is exponential in what the user places on the axis, and the + cap never bounded it: 12 categories of 4 items on rows is already 16M rows. + `prune_empty` is what keeps it drawable. +- `improvise-gp` bounds its encoded width separately (`MAX_FEATURES = 64`), + so a wide fit is refused by name rather than slowed. + +**Measured**, `cargo run --release -p improvise-core --example +category_width_bench`, on an Apple M4 Max. Each category has 4 items and each +record carries 2 measures. The pivot is one category on rows, `_Measure` on +columns and the rest aggregated away. Figures are the best of 5 (the store +build is the best of 3), and they held across two runs. + +| categories | records | build store | pivot, every cell | records build | records draw | +|---|---|---|---|---|---| +| 12 | 2,500 | 26–30 ms | 1.6 ms | 79 ms | 1.6 ms | +| 24 | 2,500 | 80–83 ms | 3.0–3.7 ms | 82 ms | 3.7 ms | +| 12 | 5,000 | 54–57 ms | 3.2–3.9 ms | 302 ms | 3.1 ms | +| 24 | 5,000 | 163–172 ms | 6.3–8.0 ms | 321 ms | 7.0 ms | + +Doubling the width doubles the per-cell work and triples loading the store, +which also builds and hashes CellKeys twice as long. The records-mode build is +the outlier, and width is not its cause: it grows 4× when the **records** +double, at either width. `RecordRow::group` finds each cell's row with a linear +scan (improvise-sk32). + +#### Coupling found + +Raising the constant was used to look for code that assumes few categories. +This inventory was taken at felo.1's merge of `gh/main` `4f38a35e`. Line +numbers are dated, so re-derive them before citing. + +**Reads the constant (fixed in felo.1).** The wizard's precondition, and the +duplicate step's cap check that improvise-cgqt added (`separate_blocked`). A +fixture that fills to the cap either derives it or fills until +`add_category` refuses. + +**Separate limits, left alone.** None of these is a category count: + +- `MAX_ROW_HEADER_W = 24` (`improvise-protocol/src/render.rs`) is the widest + one header level is drawn, in cells. It equals the new cap by coincidence. +- `MAX_FEATURES = 64` (`improvise-gp/src/fit.rs`) is the encoded-width budget + of one fit. +- `CATEGORY_THRESHOLD = 20` (`import/analyzer.rs`) is the distinct-value count + at which a text field becomes a `Label`. + +**Degrades as categories grow.** These are not fixed here; each is a place +where 24 is worse than 12. Most were already reachable at 12. + +- **The TUI row header is the sum of its levels** (improvise-gtn7.5). In `src/ui/grid.rs`, around + `row_header_width`/`data_area_width` (and + `ui::viewport::compute_row_header_width`), each level takes 4 to 24 cells. + Categories on Row therefore cost at least 4 cells each, and the data area + saturates to zero width with no data drawn. +- **Column headers take one line per level, but the keypress path does not + count them** (improvise-gtn7.6). `App`'s `visible_rows` is `term_height − 8`, a fixed allowance + that ignores the column levels, the group row and the page bar. So with + several categories on Column, the cursor can scroll below the drawn grid. +- **The page bar is one unclipped line** (improvise-gtn7.7). It is + `GridWidget::render`'s `page_coords` join, drawn with a bare `set_string` + that is not clipped to the inner rectangle, so a long bar can overwrite the + block's right border. Default seating (`View::on_category_added`) sends every + regular category after the second to Page. A 24-category import therefore + puts 22 selectors on one line, clips most of them without a mark, and opens + on a single slice. +- **Side panels do not scroll** (improvise-gtn7.8). In `src/ui/panel.rs`, an item past + `item_height` is not drawn, and the cursor can move onto it unseen. The + category panel lists every category plus its expanded items. +- **The wizard's field list does not scroll** (improvise-8jk6.1). Its `ReviewProposals` arm in + `src/ui/import_wizard_ui.rs` breaks at the popup's bottom (the popup is at + most 30 rows high), unlike the steps that use `scrolling`. About 22 fields + fit, so the over-the-cap case cannot show every field it asks the user to + fix. +- **Loading the store is O(k log k) per cell and O(cells × k) in memory** + (improvise-felo.2). It + measured 3× from 12 to 24 (the table above). +- **A fit declines more columns.** `unaffordable_categories` drops the widest + category columns once the encoding passes `MAX_FEATURES`. A wide document + is fit on fewer of its categories, and the fit says so. +- **`cross_product` is exponential in the categories placed on one axis**, and + `prune_empty`'s `has_value` matrix is rows × columns. The cap never bounded + either. Virtualizing the grid is improvise-felo.3. +- **Header repeat suppression costs O(levels) per position and level** + (`render.rs::shows`). That is O(rows × levels²) per frame, which is small at + 24. + +The records-mode build's quadratic (improvise-sk32) is not category coupling; +it is named in the measurement paragraph above because this measurement found it. + +The browser adapts where the TUI does not. `#tiles` and `#categories` scroll, +and `web/shell.js` charges each column level to the extent. + ### Seating takes the kind, because a name cannot carry it (improvise-cj4u) `View::on_category_added` takes a `CategoryKind` beside the name. It used to @@ -1439,3 +1560,207 @@ editor stays open over the user's text (gotcha 21). `was` is not redundant with a stable id: a concurrent *replace* keeps the id and changes the text, which only `was` can see. + +## 19. Group hierarchies: parents nest runs, and every group starts closed (improvise-felo.6) + +`Group::parent` was parsed (`> Q1[2024]`), written, round-tripped and hashed, +and nothing read it: `Q1` was never drawn inside `2024`. It is now the +hierarchy. + +### The user's rulings + +- "I think hierarchies should start closed unless we somehow opt into a + specific one opened to a certain depth (view config maybe?)" — and the + reason: "in most cases, the closed by default start is preferable because it + makes the grid more dense." +- "Everything opens closed, including old files." There is no version gate: a + file written before this change opens with its groups closed, and its + `collapsed:` lines simply agree with the default. +- "I think we'll need a depth for expanded, something like expanded(2): ...". +- "expanded+collapsed in the same group is an error". + +### The model + +- **A group's chain** is the group and its ancestors, root first + (`Category::group_chain`). An item's chain is its group's + (`Category::item_chain`); an ungrouped item has none. A group's depth is its + number of ancestors. +- **A group covers every leaf beneath it.** `items_in_group` reads each item's + chain, so `2024` covers the months of `Q1-2024` and `Q2-2024`. That is the + whole change to evaluation: `evaluate_covering` already expanded a group + coordinate through `items_in_group` (§14), so a year's fold is the fold over + its months with no second path. +- **An item may sit directly in a parent group, beside its child groups.** + Chosen because nothing is simpler: the chain handles it (`Jun[H1]` after a + run of `Q2` inside `H1` ends the `Q2` run and stays in `H1`), and refusing it + would be a rule with nothing to protect. A date import never produces one. +- **A cycle is refused on load, naming its members** (`Refusal::HierarchyCycle`, + `persistence/parse.rs`). A cycle built in code cannot hang anything either: + `group_chain` stops where it would repeat. +- **There is no such thing as a dangling parent in this format.** A root group + is declared *only* by being named as a parent: the writer emits `>` lines + only for groups that have a parent, and nothing else can name `2024`. So + "a parent that names no group" and "a root" are the same line, and refusing + one refuses the other. + +### Which groups are open + +State lives on the `View`, in two places that cannot disagree about one group: + +- `expand_depth: cat → N` — `expanded(N): Cat`, the category's top N levels + open. +- `group_folds: cat → group → GroupFold` — ONE map, so a group has at most one + fold. `GroupFold::Collapsed` is `collapsed: Cat/Group`; + `GroupFold::Expanded(N)` is `expanded(N): Cat/Group`, the group open and + N-1 levels beneath it. Two sets would have made "both" representable, which + is the state the user ruled an error + (`doctrine(an_invariant_is_made_unrepresentable_not_validated)`). + +`View::is_group_collapsed(cat, chain)` answers, in order: + +1. the group's own fold, if it has one — so a `collapsed:` group inside an + `expanded(N)` region stays collapsed, and an `expanded:` group below the + category's depth opens; +2. open, if an ancestor's `Expanded(n)` reaches it (the ancestor `k` levels up + reaches it when `k < n`), or the category's depth does (`depth < N`); +3. closed. + +**`z` writes one fold, or none.** `toggle_group_collapse` flips the answer and +records the flip as `Collapsed` or `Expanded(1)` — unless the flip lands on +what the group would inherit from rule 2, in which case it removes the group's +fold. So `z z` leaves the view as it was, and no sequence of toggles writes a +file that names one group twice +(`no_sequence_of_toggles_writes_a_conflicting_file`). Closing a group that +carried `Expanded(3)` drops the 3: reopening it with `z` opens one level. + +### The layout + +`expand_category` walks each drawn item's chain. The **outermost** collapsed +group in the chain folds everything beneath it: the row's tuple is that group's +name, it folds once however non-contiguous its members are (§14), and the +headers above it are drawn. The run markers move from item to item exactly as +`remark` moves them after pruning — past the shared prefix of the two chains, +end the deeper runs innermost-out, then open what differs — so an unpruned axis +survives `prune_axis_items` marker for marker, and the enumeration in +`group_membership` (now with a `NESTED` fixture: a non-contiguous quarter, an +item directly in a half-year, an outer grouped category) asserts it. + +A header carries its own `collapsed`, computed once in `expand_category`, so +the frame projection and the frame hash read it rather than asking the view +again with a chain they would have to rebuild. + +### Persistence + +`expanded(N): Cat`, `expanded(N): Cat/Group`, bare `expanded:` for N = 1, and +the existing `collapsed: Cat/Group` (`improv.pest`, `expanded_line`). N is at +least 1 by the grammar (`'1'..'9' ~ ASCII_DIGIT*`), so `expanded(0)` is a parse +error rather than a value to explain. Two further decisions: + +- **The writer keeps what the view holds.** An old file's `collapsed:` lines + load as `Collapsed` folds and are written back verbatim, though they agree + with the default. They are the user's explicit closes, and they survive a + later `expanded(N): Cat` that would otherwise open those groups; dropping + them on save would change what that later line means. A `z` round trip + removes such a line, because `z` normalises (above). +- **A category depth is never written bare.** `axis_line` is tried before + `expanded_line`, so a category literally named `expanded` keeps its + `expanded: row`. The price is that `expanded: row` meaning "category `row`, + depth 1" would read as that axis line, so the writer always spells the + category form `expanded(N): Cat`. The group form has a `/` and is never + ambiguous. + +A view naming one group with two different folds, or one category with two +depths, is refused on load, naming the view and the group +(`Refusal::FoldConflict`). The same line twice is not a conflict. + +The grammar-walking generator emits cycles and conflicts with ordinary +probability (`> ||[||]` is a self-parent), so `generated_file_parses` accepts a +`Refusal` — a typed error, so it cannot accept a parse failure by accident. + +### Known limits + +- The TUI draws nested headers exactly as it draws any other: one line per + header, not indented by depth, and the column band shows each column's + innermost group only. Both are pre-existing presentation choices; the frame + carries `depth` for the day a front end wants to indent. +- Two `>` lines giving one group two different parents: the last wins, as it + did before this change. That makes the hierarchy a function of line order. + +## 20. How an id is made — `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2) + +`slugify` and `IdAllocator` are the first piece of ADR 007. They are pure +functions of the display and the allocator's own history. Nothing calls them +yet: the creation sites move onto them in the re-key step (improvise-cq1q.4). +Each rule traces to a numbered answer in the ADR's "What the user decided". + +**Case folding is full Unicode folding, and it comes from `unicase`.** +`str::to_lowercase` is not folding: `ß` lowercases to itself and is then +stripped, so `Straße` would slug to `strae` (answer 26). The same goes for `ſ` +(long s) and the `fi` ligature. `unicase` 2.9 is `no_std` and has no +dependencies, so it passes the wasm gate. Its `to_folded_case` applies the +C+F mappings of Unicode's CaseFolding.txt, and its own tests pin `Maße` → +`masse`. Most of what folding produces is stripped anyway: only an ASCII +letter survives step 3. Deriving that ASCII-producing subset of the table by +hand was rejected. It is a second copy of the Unicode data, and nothing would +say when it drifted. + +**A "space" is U+0020.** Answer 18 says "replace space with `_`", and answer 21 +says runs collapse. A tab or a no-break space is not a space here, so it is +stripped by step 3. Only a run of U+0020 becomes one `_`. An underscore the +display already had is never merged with the one a space run makes. So `a_ b` +slugs to `a__b`: the steps are applied as written, and no extra +underscore-collapsing step exists. A trailing run survives, so `East ` slugs +to `east_`. Only *leading* underscores are removed (step 4). + +**The allocator records every id it admits and releases none.** That is the +whole of retirement (clause 4, "within a session a removed id is never handed +out again"). No release operation exists, so a removed id cannot be reissued +by construction; no check refuses it. The counter starts at 1, is shared by +every kind, and advances each time a suffix is tried, including a candidate +that turns out to be taken by a hand-written id (answers 28 and 29). So +`east-7` may follow `east` directly, and no order is enforced among suffixes. +**The walk past taken candidates is a loop, never recursion.** A +new-version file saved from a non-ASCII import holds `item-1` … `item-N`, and +the counter restarts at 1 on load, so one generation walks all N. The first +version recursed once per candidate, not in tail position, and aborted the +process on a stack overflow: in debug at N ≈ 11,000, in release at 50,000. +An abort, not a panic, so the TUI lost unsaved edits (Codex P2 on PR #318). +An empty slug always takes a suffix (`category-1`), even when the bare kind +name happens to be free, because answer 16(e) spells the result +`-`. + +**`claim` is the only door for a hand-written id.** It checks, in this order: +the alphabet (non-empty, `a`–`z`, `0`–`9`, `-`, `_`), the reserved leading +`_` (only `_index`, `_dim` and `_measure` may carry one, per answer 23, and +the match is exact: `_indexer` and `_measure-1` are Reserved, since "no other +id may start with `_`"), and +duplicates. It never renames. Refusing is the loader's job, and the loader +must name both lines of a duplicate; `ClaimError` carries only the id, so the +loader keeps the line numbers. The virtual views `_records` and `_drill` are +not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive +with the ADR text fix (improvise-cq1q.1) rather than ahead of it. + +## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3) + +`Category`, `Item` and `View` each carry a private `display`. It starts equal to +the key and changes only through `set_display` / `set_item_display`, which move +no key. Until the re-key (improvise-cq1q.4), nothing in production calls those +setters, so `display == name` everywhere and behaviour does not change. The +split exists so that the re-key changes what goes *into* the key, and nothing a +person sees moves with it. + +**The key travels beside the display, never in place of it.** `CategoryRow`, +`TileEntry` and `ViewSummary` each gain a `display` field. The key field +(`name`, `category`) stays exactly what it was, because both front ends compose +commands from it: `resolve.rs` builds `SetAxis`, `RemoveItem` and the rest out of +those strings. Carrying the display *in* the key field would make every +command name a key that does not exist once the two differ. That is the gap +improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The +active-view marker in both view panels still compares keys for the same reason. + +**What this first slice covers:** the category panel's headers, the tile bar and +the view list, in both front ends. Item rows, the grid frame, the page bar, +records-mode labels, CSV export headers, status messages and the view title +still show keys. They follow in later slices of this step, because the grid's +label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their +display has to be added at projection time rather than in place. diff --git a/context/code/doc-site.md b/context/code/doc-site.md index 44a8a934..cfd4b734 100644 --- a/context/code/doc-site.md +++ b/context/code/doc-site.md @@ -181,6 +181,15 @@ The general form: when a check misses a defect, add the check that could have seen it rather than only fixing the defect, and ask what *question* the existing check was answering. +**`site/` is build output and is not tracked** (improvise-1xfb.4). It is +`web/assemble.sh`'s default `OUT_DIR`, and `site/docs` is this script's. +Every reader builds its own copy from source: CI's `build` job runs +`nix build .#site`, which assembles into the store; the harness assembles +into `harness/serve/`; `scripts/build-web-release.sh` assembles into a +temporary stage. For a while 27 generated pages under `site/docs` were +committed by accident. They went stale, still saying 12 categories after the +cap was raised, and nothing read them. + ## 5. Why a node script and not a generator The landing page is hand-written HTML with no build step and one pinned CDN diff --git a/context/code/formula-and-io.md b/context/code/formula-and-io.md index 89414b9c..9a8b88ad 100644 --- a/context/code/formula-and-io.md +++ b/context/code/formula-and-io.md @@ -165,7 +165,7 @@ share, which is why the existing suite was silent — the same shape as the `Category::kind` rather than the emitted text. The consequence was a lost document, not a lost flag. `Label` sits outside -`MAX_CATEGORIES = 12` on purpose, and the import wizard's own precondition +`MAX_CATEGORIES` on purpose (12 then, 24 since improvise-felo.1), and the import wizard's own precondition scores a label as zero, so it will happily build twelve categories plus a label. On load every label came back `Regular`, the thirteenth was refused, and `parse_md` failed: **any document where regular + label exceeded twelve was @@ -218,6 +218,32 @@ times. The probe was removed rather than kept: its rate depends on proptest's internal RNG, so as a permanent assertion it would fail on a dependency bump for a reason unrelated to the format. +### Folds are view lines: `collapsed:` and `expanded(N):` (improvise-felo.6) + +Groups start collapsed, so a view persists what it opens. `expanded(N): Cat` +opens a category's top N levels, `expanded(N): Cat/Group` opens that group and +N-1 levels beneath it, a bare `expanded:` is N = 1, and `collapsed: Cat/Group` +keeps its old meaning. The semantics, the precedence and the refusals are +`context/code/core.md` §19; what is particular to the format: + +- **No version bump.** The line is additive, and the user ruled that old files + open closed like new ones, so `v2025-04-09` still reads every file it did. +- **N >= 1 is grammar, not validation**: `depth_number = @{ '1'..'9' ~ + ASCII_DIGIT* }`. Spelled with a `Range` and `ASCII_DIGIT` rather than + `ASCII_NONZERO_DIGIT`, because the generator knows the first two and would + emit nothing for the third — `expanded(): ` — a file its own grammar refuses. +- **`axis_line` comes before `expanded_line`** in `view_entry`, so a category + named `expanded` keeps its axis line, and the writer never spells a category + depth bare (`expanded: row` would be that axis line). +- **Two refusals the grammar cannot express** — a cycle among `>` lines and one + group given two folds — are a typed `Refusal`, which `generated_file_parses` + accepts and nothing else does. The generator produces both at ordinary rates. +- **The generator reaches the new rule**, asserted by + `the_generator_writes_expanded_lines_with_and_without_a_depth` over 512 + deterministic files. Unlike the kind-line probe above, it asserts that each + spelling occurs at all, not a rate, so a proptest bump fails it only if the + walk stopped reaching the rule. + ### The tree walkers are infallible by construction Every `build_*` in `parser.rs` operates on a `Pair` pest has already @@ -587,6 +613,59 @@ different row order on screen, and a different detected date format, since varied. The `HashSet` in `analyze_records` is there to deduplicate and **never to iterate**. +### A date column imports as ONE hierarchical category (improvise-8jk6.3) + +The user: "I think dates should usually be one category and we can use +formulas to crosstab." So the default `DateShape::Hierarchy` builds one +category named after the column, where the old import built up to three flat +ones beside it (`Date_Year`, `Date_Quarter`, `Date_Month`): + +- **Its items are the finest chosen component**, spelled so they stay unique + across years: `2024-01`, `2024-Q1`, `2024`, which are + `extract_date_component`'s existing spellings. A month is never `Jan`. +- **The coarser chosen components are its groups**, coarsest first + (`DateComponent::COARSE_TO_FINE`): a month sits in its quarter, and the + quarter's `>` line names its year. A level left unchosen is skipped rather + than drawn empty, so Year + Month groups months straight into years. +- **It opens folded**, one row per year, with nothing to set, because every + group starts closed (`context/code/core.md` §19). +- **It costs one of the category slots**, not up to four, and + `proposed_category_count` counts it that way. +- **The raw dates are gone from that category.** The day is not a component, so + two records of one month land on one cell and the collision step asks, as it + does for any other collision. `Split` keeps the day. +- **A value the detected format cannot read keeps its raw text** as an + ungrouped item, with a warning naming the column and the row count, rather + than being dropped. The warning is separate from the split shape's, because + that one reports an absent coordinate and this one a coordinate that is there + and ungrouped. **Unless its spelling is one the hierarchy generates** + (improvise-d0n6): a raw `2024-01` or `2024` is the same name as the generated + month or year, so it would join the grouped item and the warning would be + false. The build **refuses**, naming the column and the spellings and pointing + at the split shape. That follows the collision rule below: where two records + would silently be one thing, the user is asked and nothing is defaulted. A + fallback to the split shape was rejected as a silent change of the user's + choice, and a renamed raw item as altering the data. The check is + `Plan::spelling_clash`. +- **Item order is first-seen, not chronological.** Order is a correctness + property here (gotcha 18), and a group need not be contiguous, so a CSV that + is not in date order still nests correctly. It is just drawn in the order + it was read. + +`DateShape::Split` is the old behaviour, unchanged, and stays a choice. `s` on +the wizard's date step toggles the field under the cursor, and the header line +for each field says which it will get ("one category: Year > Quarter > Month" +or "split: Date, Date_Year, …"). It is worded once, in +`project.rs::date_shape_sentence`. Headless, `--split-date ` does the +same, and `--extract` keeps its meaning: which components to build. + +**Crosstabs by formula are not available yet.** A formula can name a month +(`SUM(Amount WHERE Date = "2024-01")`, `Date IS "2024-01"`), but a `WHERE` or +`IS` naming a GROUP matches no record: both compare the coordinate to the name +exactly. `SUM(Amount WHERE Date = "2024")` is an empty sum, and `Date IS "2024"` +is 0 on every month of 2024. That was measured on this branch and reported for +filing, not built here. + ### The ambiguous-date heuristic reads American-first, deliberately `01/02/2025` parses under both `%m/%d/%Y` and `%d/%m/%Y` and nothing in the data @@ -632,6 +711,256 @@ one proves nothing. backend rather than only a file (improvise-fuj); the path-taking wrappers remain for the native CLI. +### A CSV field is text, and the field's role reads it (improvise-8jk6.4) + +The parser used to type every field as it read it: text that parsed as an +`i64` or a finite `f64` became a `Value::Number`. That is a lossy step, and it +ran before anyone knew what the column was for. A column the user then forced +to Category (`--category`, or the wizard's `c`) took its item names from the +number's display, not from the file. So commit `00565796` became item `565796`, +and `220944e2` was read as scientific notation and became `22094400.0`. The +repo-stats pipeline (improvise-1xfb.5.1) found it. + +CSV has no types. Whether `00565796` is a number depends on whether its column +is a measure, and the parser cannot know that. So the parser now yields every +non-empty field as a `Value::String` of its text (blank is still `Null`), and +two readers apply the old numeric rule, `csv_parser::number_in`, only where a +number is wanted: + +- **Analysis** proposes a kind from a typed copy of the records + (`Scalars::typed_record`), so a numeric CSV column is still proposed as a + Measure, exactly as before. +- **A measure** takes its value through `Scalars::number`. + +A category or a label is named from the text itself, so it is verbatim. + +`ImportPipeline` carries the reading as a private `scalars: Scalars`: +`Typed` for JSON and `Text` for CSV. JSON cannot share the text rule, because +a JSON string is text by the file's own say. `"02139"` in JSON is a zip code, +and reading it as a number would propose it as a Measure. A JSON number +forced to Category has no source text to keep, because serde_json does not +keep one. It is named by `Value`'s own display, which is what writing it back +as JSON would say: `42`, `1.5`, and `1000.0` for `1e3`. + +**The parser returns `CsvRecords`, not `Vec`.** It derefs to +`[Value]`, so a caller can read it. It converts to a `Vec` only inside the +crate, and the only public way into a pipeline is `ImportPipeline::from_csv`. +This is not caution for its own sake. When the parser first changed, eleven +tests in four files failed. Every one handed parser output to +`ImportPipeline::new`, the JSON constructor, which would read every column as +text and propose `Revenue` as a Category. Production had the same shape at all +three CSV entry points: `main.rs`'s single file, `ImportPipeline::from_merge` +(the merge path, from PR #314), and `ImportJsonHeadless`. `from_merge` merged +in with `Self::new` and now calls `Self::from_csv`. Now that mistake does not +compile. Two holes remain, and +both take a deliberate step. `ImportPipeline::records` is still a public +`Vec`, and a test helper once copied it out of a CSV pipeline into a +JSON one. And `CsvRecords` derefs to a slice that `.to_vec()` will copy. + +### An import refuses what it cannot keep (improvise-8jk6.5, improvise-8jk6.6) + +Two failures used to end in an import that saved and reported success. + +**A formula that does not parse.** `build_model_with_warnings` ran +`if let Ok(formula) = parse_formula(..)`, so a bad `--formula` vanished. The +headless run then saved and exited 0, and the wizard kept the bad text in its +list and dropped it the same way at build. Both now go through one +function, `import_formula`, whose error names the formula and carries pest's +parse error: `Formula error in 'Y = x +': …`. The prefix matches the TUI +formula minibuffer's `Formula error:`. + +- `ImportPipeline::parsed_formulas` parses them all. If any fails, it refuses + and names **every** formula that failed, one per line (a message the user + needs is never truncated). The build calls it before creating anything, so a + refusal writes nothing. +- The wizard checks at `confirm_formula`, the one place the user can still fix + the text (gotcha 22). A formula that does not parse stays in the buffer with + the error as the step's message, and never enters the list. + +**Nothing kept.** The one skip left after improvise-8jk6.2 is a record that +is not a JSON object. When every record is skipped, there are no fields, so +there are no proposals and no categories. The build did refuse that case, but +by accident, with "At least one category must be accepted". That tells the +user to change a field's kind when there are no fields. The ticket's own repro +(`[1, 2, "three"]`) already exited 1 on the lpzu base, so the headline did not +reproduce there. What did reproduce was the reason. `nothing_to_import` now +states the rule and runs before the category check: `Nothing to import: all 3 +records were skipped as not JSON objects`, or `the source has no records`. The +wizard asks it first on `ReviewProposals`, where another array path can still +be chosen. + +One case meets PR #314's "a header-only file is still a file". A folder whose +CSVs are **all** header-only has no records, only sources. Built from +`from_merge`'s seeded proposals alone, it would save `File` items, empty +columns and one empty records view per file. It is refused here, because it +kept no record. A header-only file *beside* files with rows is unaffected, +since some record was kept. The user confirmed this on PR #321: "Refuse (as +#321 does)". + +**A partial skip stays a warning.** Some records kept and some skipped still +builds, exits 0, and warns with a count: `Skipped 1 of 3 records that were not +JSON objects`. The argument: a non-object in an array of records is not a +record that failed to import. It is structurally not a record, such as a +trailing `null` or a stray scalar in a hand-edited file. Every record that +*is* a record was kept, and the count is on stderr for a script to read. +Making it fatal would fail every import that carries one stray element. +Making it a distinct non-zero exit that still writes gives a contract where +`import … && next` stops even though the file was written. + +The user ruled on PR #321: "Add a --strict flag". The default stays exit 0 +with the counted warning, pinned by +`an_import_that_skips_some_records_warns_with_a_count_and_still_writes`. +`import --no-wizard --strict` turns any skipped record into a refusal: +`--strict: Skipped 1 of 3 records that were not JSON objects`, exit 1. + +**A strict refusal writes nothing.** A refusal that still wrote the file would +be the half-met contract argued against above: `&&` stops while the output +sits there, and a script that checks only the file gets a model that `--strict` +existed to prevent. So the check runs in `run_headless_import` before the +build. It reads `ImportPipeline::skip_warning`, the same function the build's +warning comes from, so the two cannot disagree about what was skipped. +"Skipped" here means a record that is not an object, the only skip left after +8jk6.2. An unparsed date or a short CSV row is a warning about a record that +was kept, and `--strict` does not refuse it. + +### A folder of CSVs is one sparse model, with a view per file (improvise-lpzu) + +`improvise import ` exists for improvise-1xfb.5.1. code-maat writes one +CSV per analysis into a directory, and the schemas differ: coupling has +`entity,coupled,degree,average-revs`, age has `entity,age-months`, and +abs-churn has `date,added,deleted,commits`. The user ruled (2026-09-25): +"Records are already sparse: the model can just leave null/empty values for +columns not in a given CSV file." So files with different headers merge into +**one** model. It is not refused, and it is not split into one model per schema. + +**A folder argument means exactly its `*.csv` files.** `expand_import_paths` +takes the arguments in order and replaces each folder with its `*.csv` +entries. The extension check is case-insensitive, the listing is not recursive, +and the order is `Storage::list`'s, which is sorted by contract. Then the +existing path runs, with one difference: **a folder always merges**. Two or more +files merge through `merge_csvs_from` with a `File` category, and so does a +folder holding a single CSV. That gives the one file its `File` item and its +per-file view. + +The single-CSV rule is **the user's ruling (2026-09-25)**, which replaced this +PR's first answer, "`import dir/` is exactly `import dir/*.csv`". Their words: +"yes, we might want to have some kind of update import eventually (don't plan +this yet, but we'd want file identity for this)". A folder names a collection +that may grow, so a document built from it should record which file each +record came from from the start. + +**A single CSV named directly, not through a folder, still imports alone, +without `File`.** That is deliberate. Naming one file asks for that file, not +for a collection, and the shipped recipes (`examples/demo.improv` from +`examples/demo.csv`) and every existing single-file script would otherwise gain +a `File` category and a second view. File identity for update-import can be +asked for by naming a folder. The test is +`a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not`. + +Apart from that, a folder still behaves as the glob would, and four cases follow +from that rather than being chosen one by one: + +- **Non-CSV entries are skipped**, not refused, because a glob skips them. This + includes the output `.improv`, which a second run finds in the folder. +- **A folder with no CSV at all is refused**, naming the folder. A glob that + matches nothing is an error too, and importing nothing would write an empty + document. +- **Files and folders mix freely**, in argument order. +- **A file named twice is read twice.** For example, `dir/a.csv dir/` reads + `a.csv` twice, and its records collide with themselves. The headless path + refuses that like any other collision. Nothing deduplicates, because the + shell glob would not either. + +The listing goes through `Storage::list` so the browser host can reuse it +(gotcha 2). `Storage` knows documents, not folders, so **whether an argument +is a folder is the caller's question**. The CLI answers it at the edge with +`Path::is_dir` and passes an `ImportPath::{File, Folder}`. An OPFS host would +answer it from its own UI. + +**Sparse merge.** A record from a file without some column has no key for it, +so `Plan::place` gives it no coordinate there (improvise-8jk6.2 above), and no +measure cell. Nothing is fabricated: no `0` and no `""`. The analyzer already +classified each column over the values present (improvise-l1f5), so a column +only one file has is judged by that file's values alone. The category cap +counts the **union** of every file's categories, header-only files included +(see the next paragraph). Rows from different files cannot collide, because +`File` differs. One caveat on "judged by its values": a JSON `""` is not yet +dropped by the analyzer the way `null` is, so a JSON numeric column holding +`""` is proposed as a Label (improvise-8jk6.11). CSV is unaffected, because +its parser turns a blank field into `null`. + +**A header-only file is still a file** (the Codex P2 on PR #314). code-maat +writes a header and no rows whenever nothing passes its threshold, so +`coupling.csv` is often exactly that. The pipeline is analyzed from *records*, +and such a file has none, so it had no `File` item, its columns never reached +the model, and its view filtered to an item that did not exist. +`ImportPipeline::from_merge` therefore **seeds from `Merged::sources`**: + +- the `File` proposal's items are the sources' items, in file order, so every + file has its item whether it has rows or not; +- a source column that no record has a value in is proposed as a **Label**. + That is the analyzer's own verdict for an all-null column (improvise-l1f5), + and a column with no values is exactly that: nothing can be classified, so it + takes no pivot slot and does not count toward the category cap until the user + reclassifies it in the wizard or with `--category`/`--measure`. + +This is also the file identity the user wants kept for a future update-import. + +**A file with no measure column gets a records view** (improvise-8jk6.13). A +record with no measure makes no cell, so such a file contributes items and no +data. Its view had nothing to page `_Measure` to, and paged onto another file's +measure, which the same view hides; the cell there was a `Record`, editable, +under this file's `File`. Now `source_view` gives such a file the records +layout (`view::records_steps`, the one home of what a records view is) with +`File` still on Filter at the file's item. It draws zero rows, because there +are no cells. A header-only file whose columns are +all Labels is the same case. In records mode the columns are the whole model's +categories and measures, not just this file's: records mode has no per-view +column filter. With no rows that is cosmetic. + +**Each file gets a view** (the user, 2026-09-25: "dir import should do this: +one view filtered by the file column and projected to the columns in that +file"). `merge_csvs_from` returns a `Merged` whose `sources` list each `File` +item with the columns its header named. Two files with one stem share an item, +so their columns are unioned. The pipeline carries `sources`, and the build +adds one view per source after the merged `Default` view, which stays the +initial view. Each per-file view: + +- **Is named after the file's stem.** A name already taken, including the + virtual `_Records` and `_Drill`, gets ` 2`, ` 3` and so on. A view named + `_Drill` would never be written and would be dropped on load. +- **Puts `File` on `Axis::Filter`, fixed at that file's item.** Filter and + not Page, because the view *is* that file's projection. Paging to another + file would show that file's records through this file's columns and hidden + measures, which is wrong on every page but one. Filter constrains without + being navigable (gotcha 27). Until now it appeared only in the never-saved + `_Drill` view, but the `.improv` grammar has always round-tripped it. + `per_file_views_survive_a_round_trip_and_repeat_byte_for_byte` proves that on + a saved per-file view, and `a_saved_per_file_view_projects_only_its_file` + proves the shared `grid_in` projection, which both the TUI and the browser + draw from, carries it as a fixed coordinate with nothing to cycle. +- **Seats the file's own categories** (its columns, plus date parts derived + from them) through `View::on_category_added`, in model order: the rule a + single-file import uses. Every other category goes on `None`. The file's + records lack those, so nothing splits. +- **Puts `_Measure` on Page**, as `Workbook::new` does, **with other files' + measures hidden** and the page selection on the file's first measure. A + formula target comes from no file, so it stays visible. A file with no measure + gets a records view instead (above). +- **Is never named like another view from the same import.** `Default.csv` and + `Default 2.csv` become `Default 2` and `Default 2 2`; without the same-batch + check both would be named `Default 2` and one view would silently replace the + other. + +A per-file view is made only while `File` is an accepted category. A user who +skipped or reclassified it has no filter to build views from. + +Two things this does not do. Page cycling still steps onto a hidden item and +draws an empty grid: `[`/`]` in a per-file view can reach another file's +measure (improvise-yd2t; the Default view already behaved this way). And a +CSV with its own `File` column has it overwritten by the merge's, which is the +existing behaviour of `merge_csvs`. + ### The wizard's back path is a history stack `advance` pushes the step it leaves; `previous` pops it. Keeping the path @@ -643,8 +972,9 @@ Preconditions live in `validation_error()` and are checked **on the step where the user can still fix them** — never deferred to `build_model` after the final confirm. Two gates earn that: too many categories (improvise-mzv) and no category at all (improvise-gfq); both are fixable with `c` at `ReviewProposals`, -and both used to refuse only at the last keystroke. `MAX_CATEGORIES` here mirrors -the model's private limit and counts regular categories only. +and both used to refuse only at the last keystroke. `MAX_CATEGORIES` here is the +model's own constant, which counts regular categories only. Until improvise-felo.1 +it was a private copy (core.md §9). `NameModel` is terminal for `advance`: leaving it is a **build**, not a step change. `build()` parks the workbook in `built` on `Done`; a build failure leaves @@ -657,10 +987,35 @@ and a blank string are **absences, not names**; stringifying them used to invent an item literally called "null" (improvise-ych). Every other value keeps its printed form. -A record missing a value for an accepted Category field has no place in the grid -and is dropped; `build_model_with_warnings` counts it against the *first* -category it is missing and reports the total, because without a warning that -loss is invisible in a thousand-row import (improvise-5j9). +**A record with no value for a category keeps its cell, without that +coordinate** (improvise-8jk6.2). Records are sparse: a `CellKey` names only the +coordinates it has. So a missing field, a `null` and a blank string all mean +"no coordinate here". None of them makes an item: not `"null"`, not `""`, and +no synthetic placeholder. The same rule covers labels, which used to become an +item named `""`. The user's words, 2026-09-25: "this limit in import should be +fixed, it dates to when this wasn't sparse." That limit was improvise-5j9's +rule: such a record "has no place in the grid" and was dropped, counted and +reported as `Skipped N of M records missing category values`. 5j9's warning +made the loss visible, and the loss is now gone, so the warning went with it. +The only record still skipped is one that is not a JSON object. + +Where such a record shows up is decided by the axes, not by the import. The +test is `a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid`: + +- **The category on Row or Column:** no position holds the record. Its other + coordinates may still draw a row, but the cell under the category's items is + empty, and the column total leaves it out. +- **The category on Page:** no selection shows it. +- **The category on None:** it is aggregated like any other record. +- **Records mode:** its row shows a blank cell in that category's column. +- **`.improv`:** its Data line omits the category, and it round-trips. +- **Drill:** a drill that does not constrain the category reaches it. + +**No "(none)" position, by the user's decision (2026-09-25).** They were asked +whether Row, Column or Page should give such records a place of their own. Their +answer: "I think leaving it out for now is fine, maybe a UI element somewhere +that shows the number of records underneath the current view?" That indicator +is improvise-gtn7.10, and it is not an import concern. ### Colliding records are a question, not a default (improvise-cgqt) @@ -677,7 +1032,9 @@ made it choose too. It chose `Refuse` and points at `:import`. **Detection comes before the build and shares the build's placement.** Where a record lands is `Plan::place`: its categories, then its date parts, then its -labels, then its measure values, or a reason it is dropped. `collisions()` and +labels, then its measure values. Only a record that is not an object is +dropped, since a missing value is no coordinate rather than a reason to drop +(improvise-8jk6.2). `collisions()` and `build_model_with_warnings` both call it, so the question the wizard asks and the thing the build does cannot disagree. Neither has a second walk of the record. **A collision is a cell that more than one record writes**, meaning a @@ -735,14 +1092,16 @@ is already a category. `suggested_duplicate_category` lives on the pipeline, so the CLI's `separate` uses the same rule. **At the category cap, Separate is shown and disabled.** The reported import -already had 12 regular categories, which is `MAX_CATEGORIES`. Option (b) adds a +had 12 regular categories, which was `MAX_CATEGORIES` when this was written. +Option (b) adds a **regular** category, and the user declined a hidden virtual one. So at the cap the option is drawn "(unavailable)" with its reason and remedy: go back and Skip a column or make it a Measure, or choose Sum. The reason is decided in `ImportWizard::duplicate_options`, so every front end words it the same way. -This is the conservative reading, and it is **an open question for the user**: -lifting the cap for this one category, or counting it outside the cap, is a -model-layer decision this ticket did not take. +This was the conservative reading, and it left an open question for the user. +**The user answered it on 2026-09-25 by raising the cap to 24** +(improvise-felo.1), so "keep separate" is available for the reported import +now. The option is still disabled for any import that fills the new cap. **Headless paths refuse by default.** `improvise import --no-wizard` has nobody to ask, so `--on-duplicate refuse|sum|separate` defaults to `refuse`. diff --git a/context/code/protocol.md b/context/code/protocol.md index 81151eb8..30f4ec7d 100644 --- a/context/code/protocol.md +++ b/context/code/protocol.md @@ -1311,8 +1311,8 @@ re-derived from `kind`, so the two ends cannot disagree about what "Measure rather than re-exported, because this crate must not depend on `improvise-io` (the wizard is an io type, gotcha 30) and a client that draws the wizard must still be able to name the step it is on. `WizardFieldKind` is the semantic -half of a colour decision — the terminal mapping lives in `draw.rs`, exactly -as `AxisColor`/`axis_fg` does (gotcha 26). +half of a colour decision: it indexes the `wizard.field.*` faces (§10), and the +terminal mapping is `draw.rs::face_style`. ### `Projection` carries no multi-widget variant @@ -1474,6 +1474,22 @@ sequence. The header's level also gives a client the group's category, the dead field improvise-7xyx removed: `level` is read by `groups()`, and the category name is not carried twice. +**Within one category runs nest by `depth` (improvise-felo.6).** A group's +parent (`> Q1[2024]`) puts the quarter's run inside the year's on the SAME +level, so both markers also carry `depth`, the group's number of ancestors. +A run's key is `(level, depth)`, compared in that order: a header at a key +closes every run with a greater key, and so does a `GroupEnd`. With `Date` +alone on an axis, `2024-03` after `Q1-2024` ends sits in `2024`, and after +`2024` ends in nothing. Depth rides beside `level` rather than being folded +into one flat index, because a flat index would need every category's tree +height to be known before the first marker, and `level` would stop naming +`cats[level]`. Both front ends draw a nested header exactly as they draw any +other: one inline row per `Group` entry, the innermost group in the column +band. `the_two_front_ends_draw_the_same_nested_hierarchy_on_rows` and +`..._on_columns` in `tests/grid_frame_agreement.rs` hold them together, and +`the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy` does the same for the +windowed frame against the layout. + `group_runs` is unchanged. It coalesces whatever `groups()` answers, by header identity (improvise-h0iv), so the band still paints one group per position, now the innermost. One band per level would be a separate feature. @@ -1554,14 +1570,19 @@ runs. Reversing it is one comparison, and the test is renamed ## §3b. A frame's levels are checked as it arrives -A `FrameGroup.level` and a `GroupEnd { level }` name a category: they index -`cats`. `AxisFrame::groups` keeps one open run per level and sizes that stack -with `open.resize(g.level, None)`. So a level arriving over the wire decides how -much it allocates. A frame whose level is `4294967295` would ask for a +A `FrameGroup.level` and a `GroupEnd`'s `level` name a category: they index +`cats`. `AxisFrame::groups` used to keep one open run per level and size that +stack with `open.resize(g.level, None)`, so a level arriving over the wire +decided how much it allocated. A frame whose level is `4294967295` would ask for a multi-gigabyte vector, and the client would be taken down instead of the frame being declined. ChatGPT Codex found this on #250, the PR that gave levels to the wire (improvise-e265). +Since improvise-felo.6 `groups()` keeps only the headers it has received, +ordered by `(level, depth)`, so no number on the wire sizes anything and the +new `depth` field needs no bound. The level check stays for its other reason: +`cats[level]` names the group's category, which a browser `z` reads. + The bound is enforced at deserialization, by `#[serde(try_from = "UncheckedAxisFrame")]`. That is the one place a frame enters from outside. A level that is not below `cats.len()` fails to parse, with an error that names the @@ -2353,7 +2374,7 @@ deserializes now carries no English at all. `Hint { keys, command, caption }` is the unit: `keys` come off `Keymaps::default_keymaps()`, `command` is the semantic value a localized or non-terminal front end keys off, and `caption` is the default English — exactly -the `AxisColor`/`draw.rs::axis_fg` shape, with the decision shared and only the +the shape faces now have (`Face` → `draw.rs::face_style`, §10), with the decision shared and only the last translation local (`invariant(semantic_palette_at_the_core_boundary)`). ### What that bought, and what it cost @@ -2441,3 +2462,85 @@ the mode, the same shape `col_groups` uses for "no groups". `web/app.js` paints rows it does not author; `src/ui/help.rs` keeps its ratatui styles and lost ~310 lines of content that is now shared. Neither end restates what the keys are. + +## 10. `face.rs` — appearance named by role (ADR 004) + +ADR 004 (`context/adr/004-faces-and-themes.md`) is the decision; this section +is where its first two migration steps landed and what they settled that the +ADR left open (improvise-nn4s.1, improvise-nn4s.2). + +### What is here + +- `Face`: one variant per role. Where a domain enum already exists the face is + **parameterised by it** — `Face::Axis(Axis)`, `Face::WizardField(WizardFieldKind)`, + `Face::ModePanel(Panel)`, `Face::ModeTextEntryOf(TextEntry)`, + `Face::MinibufferOf(TextEntry)` — so adding an arm to that enum adds a face by + construction (clause 2). `Face::name` spells each one as a theme file keys it + (`axis.row`, `mode.text-entry.command`, `minibuffer.category-add`), and those + names are a compatibility surface once a theme can be downloaded (clause 9). +- `Face::parent`: the inheritance, **fixed in the vocabulary rather than stated + by the theme**. A theme that omits a face must still get the face's parent's + values ("a theme written today keeps working when a face is added tomorrow", + clause 9), and that is only true if the chain does not depend on what the + theme says. +- `Colour` (`Default | Named(AnsiSlot) | Indexed(u8) | Rgb`), `Weight`, `Slant`, + and `FaceSpec`, whose six attributes are all `Option`: `None` inherits, and + `Some(Colour::Default)` states "the surface" and stops inheritance. The two + are different answers and the type keeps them apart. +- `Theme { faces }` and `Theme::resolve(Face) -> ResolvedFace`, which walks the + chain once and returns every attribute concrete. **No front end walks the + chain** (clause 3). `resolve` takes a face and nothing else. +- `Theme::shipped()`: today's terminal rendering, palette-relative throughout + (clause 4). `the_shipped_theme_states_no_absolute_colour` holds that line. +- `Face::of_mode(&AppMode)` and `Face::of_minibuffer(&AppMode)`, both + exhaustive over `AppMode`, so a new mode fails the build here as it used to + in `draw.rs::mode_style` (improvise-zx8t). + +### The JSON shape + +Face names are the keys and a colour is spelled for a person: `"default"`, a +slot name (`"green"`, `"bright-black"`), a bare number for an index (`235`), +`"#rrggbb"` for `Rgb`. A misspelt face or attribute is **refused**, not +ignored (`deny_unknown_fields`, and `Face`'s `Deserialize` rejects an unknown +name): a file the user edits by hand should say where it is wrong rather than +silently draw the default. The faces map is a `BTreeMap` keyed on `Face`'s +derived order, so a re-download after an edit is a reviewable diff (clause 9). +The protocol crate derives and the front ends encode, so `serde_json` stays a +dev-dependency here. + +### Choices the ADR did not make, made here + +- **`minibuffer.*` inherits `mode.text-entry` directly, as the ADR says, and + the shipped theme pays for it.** The mode indicator is black text on an + accent, and the minibuffer is accent text on `Indexed(235)`, bold. So under + this chain every `minibuffer.` in the shipped theme states all three of + its attributes and inherits none of them from its parent. A first version gave + them a `minibuffer` base face instead. ChatGPT Codex caught it on PR #319: a + published parent is compatibility behaviour, so a theme written to the + accepted ADR that restyles `mode.text-entry` would have silently missed every + prompt. `a_theme_that_restyles_text_entry_reaches_every_minibuffer` pins the + accepted chain. Changing it is the user's call and belongs in the ADR, not in + this file. +- **`mode.tile-select` hangs directly off `mode`.** The ADR's decomposition + covers the thirteen DarkGray modes and the text-entry chain, and does not + place `TileSelect`. +- **The mode faces still state `Named(Black)` ink.** Clause 5 wants the twelve + `fg(Black).bg(accent)` sites to become `reverse` over the accent, but that is + not a no-op on a `TestBackend` buffer, and clause 8 names only one exception. + So it waits for improvise-nn4s.6 and a ruling. +- **No slot tables yet.** `Theme` has no `slots`/`dark`/`default_scheme`: they + are the browser's substitute for a terminal palette (clause 4), and the first + consumer is improvise-nn4s.5. Adding them then is a field with a serde + default, not a format break. +- **Only the faces some call site draws with exist.** The ADR's full vocabulary + (`emphasis`, `error`, `selection`, `grid.*`, `panel.*`) arrives with the + call sites that use it, because a face with no caller is a published name + with no evidence behind it. + +### The category-add exception + +`minibuffer.category-add` resolves to green in the shipped theme, where the old +literal was yellow: the user ruled the split an accident (ADR 004 Q1), and it +is clause 8's one declared exception. `AppMode::category_add` now carries +`MinibufColor::Green` too, so the browser, which still reads the wire colour +until improvise-nn4s.4, agrees with the TUI rather than drifting from it. diff --git a/context/code/tooling.md b/context/code/tooling.md index e70c874b..b8952421 100644 --- a/context/code/tooling.md +++ b/context/code/tooling.md @@ -1032,3 +1032,212 @@ changes. Two rivals were run: - reading every failure as stale fails the offline case (exit 1, wanted 2); - dropping `--locked` fails the stale case (exit 0, wanted 1). Without `--locked`, cargo quietly rewrites the lock and reports success. + +## 7. The repo-stats pipeline (improvise-1xfb.5.1) + +`scripts/repo-stats` reports churn, change coupling and doc growth over a window +of history: by default the last 7 days of `gh/main`, falling back to `HEAD`, +without merges. It reports and never gates. The user asked for code-maat to do +the analyses it has, and for improvise to be the engine for the rest. So the +pipeline is also an end-to-end run of improvise's headless product: `import`, +`script` and `export`. + +### The shape + +1. `git log --numstat --no-renames --no-merges` in code-maat's `git2` format. +2. code-maat runs `coupling` (with `--min-revs`/`--min-shared-revs`), `abs-churn`, + `age` and `soc`. Each writes one CSV into the output directory, named after + the analysis. code-maat is pinned in `flake.nix` as a `fetchurl` of the + 1.0.4 standalone jar, run by a JRE. It is in the dev shell and in the + `.#repo-stats-tools` package, but not in the `ci` shell: the JRE is about + 0.6 GB on Linux, and every CI job enters that shell. +3. The script adds `churn.csv`, one row per (commit, file) with the date, the ISO + week, the path (`entity`, code-maat's own name for it), the area, the kind + (prose, code or other), and the lines added and deleted. It also adds two + flags to code-maat's `coupling.csv`: `crosses-crate` and `agent-rules`. +4. `improvise import ` (improvise-lpzu) merges the directory's five CSVs + into **one** sparse model, `repo-stats.improv`. The merge's `File` category + names each source, and the import makes one view per file, named after its + stem and filtered to it. That is the user's ruling: "Records are already + sparse: the model can just leave null/empty values for columns not in a + given CSV file." A record keeps no coordinate in a category its file lacks. +5. `improvise script` runs the checked-in `scripts/repo-stats.improvise`. It + adds the prose-to-code formula, builds four views on top of the per-file + ones and `export`s each to `report/*.csv`. The four names (`By area`, + `By kind`, `Area by week`, `By day`) must never be a file's stem: + `create-view` of an existing name replaces that view silently (gap 17). The + suite's view count, 10, fails if one does. None of the report reads the + merged `Default` view, which can be empty for a typical code-maat pair + (improvise-8jk6.15). +6. The text report is those exports laid out as tables. The code-maat rankings, + doc sizes and the coupling flags are added to it. `--json` gives the same + data as JSON. + +The `.improv` file is the artifact. It opens in the TUI, and CI uploads it with +the CSVs. + +**A window in which no file changed builds no model.** Its commits are all +empty (`--allow-empty`), so there is no churn to import and nothing for +code-maat to analyse. The script writes `churn.csv` with its header alone, skips +code-maat and improvise, and the report says so. It exits 0. Running improvise +over an empty CSV was the other option. It was not taken, because it would test +only how improvise treats a CSV with no records, which is not what this pipeline +exists to exercise. + +**`--out` must hold no CSV the script did not write.** `improvise import .` +merges every CSV in the directory, so a stale or foreign one would silently +join the model. The script refuses one with exit 2 and names it. It refuses +rather than deleting, because deleting files in a directory the caller named is +the wrong default. Its own five CSVs are overwritten on every run, so a rerun +into the same `--out` works. + +**No collisions, by construction.** A `churn.csv` record is keyed by commit and +file, which `--no-renames` makes unique. code-maat's records are keyed by +entity, by entity pair or by date. `File` separates the sources. So the import +keeps its default, `--on-duplicate refuse`. A collision would be a bug in this +script, and the import refuses it. + +**What improvise computes and what it does not.** Everything that is a sum or a +ratio of sums is a view: +- churn by area, with `DeletionRatio = deleted / added` as a `_Measure` formula; +- prose, code and other, with `ProseToCode = prose / code` as a formula on the + `kind` category; +- area by ISO week; +- lines added per day from `churn.csv` next to code-maat's `abs-churn`. The two + columns must agree, and the suite checks that they do. + +Two kinds of output stay in Python. The ranked code-maat tables (coupling, soc +and age) are read from code-maat's own CSVs, because improvise cannot sort rows +(gap 11 below). Doc sizes are a property of files at one ref, not of the window. + +### Definitions + +- **Areas**: first match wins, in the order of `AREAS` in the script. + `generated` comes first, so `harness/opfs-contract/Cargo.lock` counts as + generated and not as harness. `.beads/` and `site/` are generated too. + `docs/*.md`, `README.md` and `RELEASING.md` are "other docs", and `.codex/` + is agent docs and skills. A Rust test is a `.rs` file under a `tests/` or + `benches/` directory, or one named `tests.rs` or `*_test(s).rs`. A + `#[cfg(test)] mod tests` inside a source file counts as Rust src, because no + path can tell the two apart. +- **Prose** is `context/code`, `context/test-evidence`, other `context/`, other + docs, `repo-map`, and the agent docs and skills. **Code** is Rust src, Rust tests, + scripts/CI, and web and harness. `kb.pl`, generated files and other files are + neither. +- **The deletion ratio** is deleted lines over added lines. It is 0 when nothing + was deleted, and blank when nothing was added. +- **A crate crossing**: both paths are in a crate (`crates//`, the root + crate's `src/`, `tests/`, `benches/`, `examples/` or `Cargo.toml`, or + `harness/opfs-contract/`), and the two crates differ. A pair with one side + outside every crate is not flagged. +- **Agent rules**: both paths are in the agent docs and skills area. Such a pair + means one rule stated in several places. +- **Gotchas** are the numbered items between `## Gotchas` and the next `---` or + `##`. The longest gotcha is measured in characters. Most gotchas are one long + line, so a line count would say little. +- **Test-evidence entries** are the `###` and `####` headings. + +### What fails, and what never does + +- **The numbers never fail anything.** No budget is checked. Budgets are a + separate decision under the compaction epic, improvise-1xfb.5. +- **improvise refusing the pipeline's input exits 3.** That covers a non-zero + exit from `import` or `script`, and any `warning:` on their stderr. The + warning rule is there because a skipped record is data loss that exits 0 + (gaps 3 and 4). It is a product regression, not a statistic. +- **In CI**, the suite runs on every PR in `nix-build` and gates like any other + suite. The report runs only on a push to `main`. Its step has + `continue-on-error`, so an improvise failure there marks the step failed and + the job and the deploy go on. Whether that failure should block the deploy + is a question for the user on the PR. The report appends to + `$GITHUB_STEP_SUMMARY`, and the directory is uploaded as the `repo-stats` + artifact. +- The report runs in `nix-build` because that job already builds + `.#improvise`. Its steps run under `nix shell .#repo-stats-tools`, which joins + that same build with code-maat, so nothing is built twice. It is one package + rather than two installables because `check-ci-graph.py` reads a counted + step as `nix shell --command`. The job's checkout fetches full history + (`fetch-depth: 0`) for the report. + +### Gaps in the headless product, and the workarounds used + +Each of these was found by running the pipeline, and each is a finding against +improvise's command and model layer. Where a workaround exists, the script +names it. + +1. **A forced `--category` value is still parsed as a number.** The commit + `00565796` imported as item `565796`, and `220944e2` imported as + `22094400.0`. Workaround: `churn.csv` writes commits as `g`. + improvise-8jk6.4. +2. **`import --formula` drops a formula that fails to parse, and exits 0.** + `--formula 'X = ('` saves a model with no formula and says nothing. + `add-formula` in a script refuses it and exits 1, so the pipeline's own + category formula is added there. improvise-8jk6.5. +3. **A record that lacks any category's value is skipped.** A merge of CSVs with + different headers, which is the user's sparse model, imports **no records**. + Closed by improvise-lpzu, whose sparse import this pipeline now uses. Until + it landed, every CSV was padded with `-` for the categories it lacked. A + related hole is still open: a record with no measure value stores nothing + (improvise-8jk6.14). +4. **An import that skipped records exits 0**, even when it skipped all of + them. It saves an empty model and prints only a warning. improvise-8jk6.6. +5. **`set-axis` and `set-page` accept names that do not exist, and exit 0.** An + unknown category, an unknown axis name (`rwo`) and an unknown item are all + silent no-ops. improvise-31oe.2. +6. **The command language does not read `|pipe quoting|`.** + `create-view |By area|` made a view named `|By`, and the second word was + ignored. Commands that take a fixed number of arguments ignore extra ones + without saying so. Workaround: `"double quotes"`. improvise-31oe.3. +7. **`create-view` puts every category it is not told about on Page**, with its + first item selected. That silently filters the view. Every view has to set + every category's axis, one `set-axis` line each. improvise-31oe.4. +8. **A view cannot hide a category's column.** A per-file records view shows + every category and measure in the model. improvise-31oe.5. +9. **`export` of a records view writes the editor's `+cat` and `+` columns.** + improvise-8jk6.7. +10. **`export` writes display text.** It uses the view's number format, so the + import's default `,.2` gives `"17,512.00"`. Workaround: `set-format .0` on + every exported view, and `FORMAT .3` on the ratio formulas. + improvise-8jk6.8. +11. **Nothing sorts.** Items stay in first-seen order, so weeks come out + newest first. A top-N table cannot be produced, so code-maat's rankings are + read from its CSVs. improvise-31oe.6. +12. **`--extract` has no ISO week.** It has year, month and quarter. Workaround: + `churn.csv` carries a `week` column. improvise-8jk6.9. +13. **`import --formula` targets only `_Measure`.** A formula on another + category needs `add-formula …` in a script. improvise-8jk6.10. +14. **The saved model opens on the import's `Default` view**, which puts `File` + on rows and `area` on columns, and not on the view the script left active. + That is improvise-azhb. +15. **No folder import.** Closed by improvise-lpzu (PR #314), which this + pipeline now uses. Until it landed, the script named the five files and + generated its own per-file views. +16. **There is no headless output but a file.** `export` writes a CSV, and the + tables are laid out in Python. improvise-31oe.7. +17. **`create-view` of an existing name replaces that view, and exits 0.** + `Workbook::create_view` inserts into an `IndexMap`, so it overwrites without + a word. Folder import makes a view per file stem, so a report view named + like a stem would replace that file's view. + improvise-31oe.8. + +### The suite + +`scripts/repo-stats-test.sh` builds a fixture repository whose every number is +known, runs the whole pipeline, code-maat and improvise included, and asserts +improvise's exported tables. It runs in `nix-build`, which puts improvise and +code-maat on `PATH`. Locally it uses improvise from `PATH`, or else +`target/debug/improvise`, and it fails with a message when there is neither. +The fallback does not check how fresh that binary is, so locally the suite can +test a `target/debug/improvise` older than the tree. Rebuild after changing +improvise. + +It does not build improvise itself. `checkers.sh` scrubs the environment to an +allowlist, and on macOS that removes what the linker needs: a `cargo build` +inside the suite failed with `library not found for -liconv`. That is a bug in +`checkers.sh`: its allowlist drops the Nix linker variables, and the +orchestrator has filed it separately. Build first with +`cargo build --bin improvise`; `cargo test --workspace` also leaves the binary +there. The suite is the slowest in `checkers.sh`, at about 25 seconds, most of +it JVM start-up for code-maat's four analyses per run. + +The evidence and the rivals are in `context/test-evidence/tooling.md`. diff --git a/context/code/tui.md b/context/code/tui.md index a792bb5e..eff93fb6 100644 --- a/context/code/tui.md +++ b/context/code/tui.md @@ -104,46 +104,49 @@ appear. Three rules: - `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`, `META`) is dropped. -### Semantic-to-terminal colour, and the one that is wrong +### `face_style` — the one place a face becomes a terminal style -This file holds the mapping design-principles §5 names as the shape everything -else should imitate: **the decision lives in the shared layer as a semantic -enum, and only the last translation is local.** +ADR 004 replaced the four per-family maps this section used to describe +(`axis_fg`, `minibuf_fg`, `field_kind_fg`, `mode_style`) with one function: +`face_style(Face) -> Style` resolves the face against the shipped theme +(`Theme::resolve`, in `improvise-protocol`) and translates the result. That is +design-principles §5's shape with nothing left over: **the decision is shared, +and only the last translation is local** — `Colour` → `ratatui::Color` and the +six attributes → `Modifier`. The chain is never walked here (ADR 004 clause 3). +What the vocabulary is and why is `context/code/protocol.md` §10. -- `axis_fg(AxisColor) -> Color` — `Axis::color` is the single place each axis's - accent is decided; this is the single place axis colouring is *rendered* - (improvise-rml, gotcha 26). Before it, the tile bar and the category panel - each carried their own copy of the colour table with nothing keeping them in - agreement. -- `minibuf_fg(MinibufColor) -> Color` — the same shape for the minibuffer. -- `src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color` — the - same shape again. `improvise-protocol` must not name a ratatui `Color`. +Three things to know. -`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and -`Color` are ratatui types and `improvise-command`, where `AppMode` lives, -carries no rendering-framework dependency), and since `improvise-zx8t` it is -**exhaustive**: the thirteen variants that take the default chrome are spelled -out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the -build here until someone chooses its colour. The arithmetic is the check a -prose count does not get: 13 + `Editing`/`RecordsEditing` + `CommandMode` + -`TileSelect` = 17, which is `AppMode`'s variant count. It read *fourteen* until -2026-09-22, and nothing could have caught that — a count written in prose is -checked by no one. +- **`Colour::Default` sets nothing.** It maps to "no `fg`/`bg` on the `Style`", + so the terminal's own colour shows through. That is also why a face with no + stated colour produces `Style::default()` and not a `Reset`. +- **The slot names follow ANSI, not ratatui.** ANSI slot 7 is `White`, which + ratatui calls `Gray`; slot 8 is `BrightBlack`, ratatui's `DarkGray`; slot 15 + is `BrightWhite`, ratatui's `White`. `terminal_colour` is the one place that + translation is written. +- **The shipped theme is resolved once**, into a `OnceLock`, because there is no + theme picker yet (ADR 004 "Deferred"). When one arrives the theme becomes the + TUI's own state rather than a static, and a theme is still never on `App`'s + `ViewState` (clause 7). -It is still not quite the same shape as `axis_fg` and its two siblings, and the -difference is deliberate rather than pending. Those three map a *semantic enum* -the shared layer owns; `mode_style` matches `AppMode` itself, because there is -no `ModeColor`. `improvise-zx8t` offered making one, and it was declined here -for two reasons: the colour is the only thing at stake, and the ticket says to -decide it together with `improvise-k099`, which asks the same question of -`hint_text`/`label` and is not in this unit. Exhaustiveness is what the -repo map's "the compiler names every one you must fill in" promise needed; a -semantic enum is a separate, larger answer and is still open. +**The mode indicator is still exhaustive over `AppMode`**, now in +`Face::of_mode` rather than here: adding a variant fails the build there +(improvise-zx8t's guard, moved with the table). The thirteen DarkGray variants +are no longer one arm: they are the five faces ADR 004 derives +(`mode.resting`, `mode.text-entry`, `mode.panel.*`, `mode.overlay`, +`mode.transient`), which all still inherit the same colours from `mode`. -**The guard is the compiler, not a test.** Nothing asserts the colours, and a -test enumerating the variants would not fail when a fifteenth is added — only -the missing match arm does. Verified by deleting the `AppMode::Help` arm: -`error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered`. +**No-op evidence.** `face_tests::the_shipped_theme_reproduces_every_migrated_literal` +holds the old literals as a table and asserts the shipped theme draws each one, +which is clause 8's property. The one deliberate difference, the category-add +prompt turning green, is asserted by +`the_category_add_prompt_draws_as_the_item_add_prompt_does` rather than being +left out of the table silently. + +**Still literal**: every other `Color::` in `src/` (the selection sites, the +panels' accents, the grid's headers, help, the welcome popup). They move as +their files are touched (improvise-nn4s.3, improvise-nn4s.6). Do not touch +`src/ui/grid.rs` for this while improvise-felo.6 is in flight. ### The minibuffer shares the bottom row with the status line @@ -580,9 +583,11 @@ to make room for a marker. `ImportWizard::scroll` clamps to `len - 1`, so the la and the list can never scroll away. It is by item, not by line, because the wizard state cannot know the panel width that line-level clamping needs. The residual is honest. A **single** item taller than the whole list area, roughly -1,500 characters at 80 columns, is still clipped at the panel's bottom. No -wizard warning is that long today: a collision tuple is bounded by twelve -categories plus labels, but a label value is not bounded. +1,500 characters at 80 columns, is still clipped at the panel's bottom. A +collision tuple has at most `MAX_CATEGORIES` coordinates (24 since +improvise-felo.1, up from 12) plus labels. With short names that is a few +hundred characters. Neither the names nor a label value is bounded, though, so +reaching the residual got easier when the cap doubled. **Every write is clipped to the panel.** The widget draws through `Panel`, whose `put` writes only inside the panel's inner rectangle, and its height diff --git a/context/design-principles.md b/context/design-principles.md index 8389be0e..3e35f6a2 100644 --- a/context/design-principles.md +++ b/context/design-principles.md @@ -69,7 +69,7 @@ same effect without the early return. three shapes; dispatch pattern-matches exhaustively. - `CategoryKind` is `Regular | VirtualIndex | VirtualDim | VirtualMeasure | Label`. - Business rules (e.g., the 12-category limit counts only `Regular`) are + Business rules (e.g., the 24-category limit counts only `Regular`) are enforced by matching on the enum, not by checking name prefixes. Virtual categories (`_Index`, `_Dim`, `_Measure`) always exist: `_Index` and `_Dim` support drill-down/records mode; `_Measure` holds numeric data fields and @@ -99,7 +99,8 @@ sort. Property tests verify this invariant. - `CellKey::new()` is the only way to build a key — it always sorts. - `Category::add_item()` deduplicates by name and auto-assigns IDs via a private counter. External code cannot fabricate an `ItemId`. -- `Model::add_category()` checks the 12-category limit before insertion. +- `Model::add_category()` checks the category limit (`MAX_CATEGORIES`, 24) + before insertion. - `Formula::new()` takes all required fields; there is no default/empty formula to accidentally leave half-initialized. @@ -268,8 +269,8 @@ why `doctrine(mirror_the_cmd_line_for_line)` is a rule about *resolvers* — the narrow, unavoidable residue — and never a licence to restate a table. **What legitimately stays per-framework.** Painting, event wiring, and the -mapping from a semantic value to a framework type: `AxisColor → ratatui::Color` -in `draw.rs::axis_fg`, `MinibufColor → minibuf_fg`. That mapping is the shape +mapping from a semantic value to a framework type: a `Face`, resolved by the +shared `Theme`, becomes a `ratatui::Style` in `draw.rs::face_style` (ADR 004). That mapping is the shape to imitate — the *decision* is in the shared layer as a semantic enum, and only the last translation is local. Anything larger than a lookup table is a candidate for the store. diff --git a/context/plan.md b/context/plan.md index 5c430be3..db8b2187 100644 --- a/context/plan.md +++ b/context/plan.md @@ -257,7 +257,7 @@ The second weekend (or the second half of the first weekend) is reserved for wha The following are real issues that have been identified but are explicitly not launch-blocking. Do not start any of them as part of this plan. They can be addressed after launch based on what real users actually trip over. -- **Do not** raise or restructure the `MAX_CATEGORIES = 12` limit, even though it's tight for some real use cases. +- ~~**Do not** raise or restructure the `MAX_CATEGORIES = 12` limit, even though it's tight for some real use cases.~~ **Superseded** by the user's decision of 2026-09-25 ("12 categories is feeling small, we should increase to 24"). The limit is 24 (improvise-felo.1). - **Do not** refactor the `ExecuteCommand` vim command match ladder, even though it duplicates registry logic. - **Do not** fix the hardcoded `"Measure"` category name in `evaluate_aggregated`, even though it's the one place in the code that assumes a specific user-facing name. - **Do not** change `Model::evaluate` from a linear formula walk to a HashMap lookup, even though it's O(N·M·F) in the render hot path. diff --git a/context/repo-map.md b/context/repo-map.md index 7b878f00..b2888ace 100644 --- a/context/repo-map.md +++ b/context/repo-map.md @@ -31,7 +31,7 @@ Architectural intent lives in `context/design-principles.md` — read that for t | Change cell storage / lookup | `model/cell.rs` → `DataStore` | | Change category/item behavior | `model/category.rs` → `Category` | | Change view axis logic | `view/types.rs` → `View` | -| Change an axis label, color or cycle | `view/axis.rs` → `impl Axis` (colors resolve in `draw.rs::axis_fg`) | +| Change an axis label or cycle | `view/axis.rs` → `impl Axis`. An axis's **colour** is `Face::Axis(axis)` in `improvise-protocol/src/face.rs`, resolved by the `Theme` and turned into a style by `draw.rs::face_style` (ADR 004) | | Ask "what data is visible" vs "what can the user page" | `View::filtering_cats()` vs `categories_on(Axis::Page)` | | Change grid layout | `view/layout.rs` → `GridLayout` | | Change `.improv` format | `persistence/improv.pest` + `persistence/{format,parse}.rs` | @@ -44,6 +44,7 @@ Architectural intent lives in `context/design-principles.md` — read that for t | Change how a name is quoted anywhere | `improvise-formula/src/quote.rs` — the one home, both halves | | Change import-wizard keys/steps | `command/cmd/wizard.rs` (keys) + `import/wizard.rs` (steps) | | Change frame layout | `draw.rs` → `draw()` | +| Change how wide the TUI grid is beside a side panel | `improvise-command/src/ui/viewport.rs` → `grid_width` (reached through `App::grid_width`); `draw.rs::draw_content` splits on it and `App::cmd_context` counts `visible_cols` on it (improvise-gtn7.1) | | Change what the browser page draws | `improvise-wasm-client/src/render.rs` → `RenderModel` | | Change a browser↔server frame | `improvise-protocol/src/frame.rs` → `ClientFrame`/`ServerFrame`. The **one** pair; both ends `pub use` it and neither defines one (improvise-xks6) | | Change the worker's message loop | `improvise-worker-server/src/worker.rs` → `Worker::on_frame` | @@ -80,7 +81,7 @@ pub trait Effect: Debug { // ModelChange::{CellData, LayoutGeometry, WholeWorkbook} ``` -`CmdContext` (see `command/cmd/core.rs`) holds immutable refs to `Model`, `GridLayout`, `AppMode`, cursor/offsets, buffers, yanked cell, key code, expanded cats, and panel/tile cursors. +`CmdContext` (see `command/cmd/core.rs`) holds immutable refs to `Model`, `Workbook`, `View`, `GridLayout`, the `CmdRegistry`, `AppMode`, cursor/offsets, buffers, yanked cell, key code, expanded cats, panel/tile cursors, the opened editor, the view back/forward stacks, `visible_rows`/`visible_cols` and the cursor cell's `display_value`. **Add a command**: implement `Cmd` in a `command/cmd/` submodule, register in `registry.rs`, bind in `default_keymaps()`. Simple wrappers go through the `effect_cmd!` macro in `effect_cmds.rs`. @@ -90,7 +91,7 @@ pub trait Effect: Debug { ## Key Types (skim; read the source for fields/methods) -**Model** (`model/types.rs`): `categories: IndexMap`, `data: DataStore`, `formulas: Vec`, `views: IndexMap`, `active_view`, `measure_agg`. `MAX_CATEGORIES = 12` (regular only). Virtual categories `_Index`, `_Dim`, `_Measure` always exist. Use `regular_category_names()` for user-facing pickers. +**Model** (`model/types.rs`): `categories: IndexMap`, `data: DataStore`, `formulas: Vec`, `views: IndexMap`, `active_view`, `measure_agg`. `MAX_CATEGORIES = 24` (regular only) — `pub`, re-exported as `improvise_core::model::MAX_CATEGORIES`, and the one home: the import wizard reads it rather than keeping a copy (improvise-felo.1). Virtual categories `_Index`, `_Dim`, `_Measure` always exist. Use `regular_category_names()` for user-facing pickers. **CellKey** (`model/cell.rs`): `Vec<(cat, item)>` — **always sorted by category**. Build with `CellKey::new(coords)` / `with(cat, item)` / `without(cat)`. Never construct the inner `Vec` directly. @@ -108,7 +109,7 @@ pub trait Effect: Debug { **GridLayout** (`view/layout.rs`): pure function of `Model + View`. `cell_key(r,c)`, `cell_value(r,c)`, `drill_records(r,c)`. **Records mode** auto-detects when `_Index` is on Row and `_Dim` is on Column. -**AppMode** (`ui/app.rs`): 17 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, SavePrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). Eight text-entry variants carry a `MinibufferConfig`. `SearchMode` is Normal + `search_mode: bool`, not its own variant. Per-variant UI facts live on the enum as exhaustive-match methods — `label()`, `hint_text()`, `panel()`, `minibuffer()`, `is_editing()`, `is_records()` (improvise-2hi); add a variant and the compiler names every one you must fill in. `mode_style()` stays in `src/draw.rs` because `Style` is a ratatui type. `App` state is split into `ModelState` (workbook, file_path, dirty) and `ViewState` (mode, cursor/offsets, buffers, panels, search) — see improvise-x2c/ew0. `wizard` and `drill_state` live on `App`, not `ViewState` (improvise-h0b) — see gotcha 24. +**AppMode** (`ui/app.rs`): 17 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, SavePrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). Eight text-entry variants carry a `MinibufferConfig`. `SearchMode` is Normal + `search_mode: bool`, not its own variant. Per-variant UI facts live on the enum as exhaustive-match methods — `label()`, `hint_text()`, `panel()`, `minibuffer()`, `is_editing()`, `is_records()` (improvise-2hi); add a variant and the compiler names every one you must fill in. A mode's colour is a `Face` (`Face::Mode*`, `Face::of_minibuffer`), turned into a ratatui `Style` only by `src/draw.rs::face_style`. `App` state is split into `ModelState` (workbook, file_path, dirty) and `ViewState` (mode, cursor/offsets, buffers, panels, search) — see improvise-x2c/ew0. `wizard` and `drill_state` live on `App`, not `ViewState` (improvise-h0b) — see gotcha 24. **Keymap** (`improvise-protocol/src/keymap.rs`; `dispatch` and `KeymapSet` in `improvise-command/src/command/keymap.rs`): `Binding { Cmd | Prefix(Arc) | Sequence(Vec<…>) }`. Lookup fallback: `exact(key,mods) → Char(NONE) → AnyChar → Any → parent`. 17 mode keymaps, one per `ModeKey`, built by `Keymaps::default_keymaps()`; mode resolved via `ModeKey::from_app_mode()`. `ModeKey` and `AppMode` are two different enumerations that happen to be the same size — `SearchMode` is a `ModeKey` and not an `AppMode`; `Quit` is an `AppMode` with no keymap — so do not "fix" one count to match the other. @@ -161,8 +162,8 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text **`prune_axis_items` takes a predicate, not a flag vector.** `prune_empty` hands it `impl Fn(usize) -> bool` over the *data* index (group headers consume no index). The domain is derived from `items` itself, so there is no parallel length a caller could get wrong — the earlier `&[bool]` + `debug_assert` pair could disagree between debug and release if a third caller ever sized `keep` from something other than `row_count()`/`col_count()` (improvise-n3wt). **It re-marks the survivors rather than filtering the markers** (improvise-e265): runs nest, so dropping an item can leave a header with nothing under it or a `GroupEnd` closing a run that was never reopened, and no per-marker filter can tell. `remark` instead emits, before each kept item, exactly the headers and `GroupEnd`s that make the output's open runs equal the input's at every level — each survivor keeps exactly the runs it was in. Reasoning: `context/code/core.md` §6. **`prune_empty` shares one `GridEval`** across the whole row×col emptiness test, and **a grid evaluates only through `GridLayout::evaluator`**, which builds the fold memo from that layout's own `none_cats` and hidden items — so no other grid's memo can reach its cells or totals (improvise-metf, improvise-opbo; the memo itself is improvise-h6z1, `core.md` §14). **`GridLayout::new` does not prune; `with_frozen_records` does** — and that split has now produced **four** bugs: `view_frame`, `project::grid_in` (PR #78), and `persistence/export.rs::render_csv` (improvise-iot6), which wrote rows *and columns* the grid does not draw for as long as CSV export has existed. Every remaining `GridLayout::new` call site in the workspace is test code — verified — so a new production caller almost certainly wants `with_frozen_records(m, v, None)`. -2. **Effects never touch `std::fs`.** Every document read or write goes through `App::storage: Box` (improvise-fuj). The host picks the backend: `App::new` supplies `PhysicalFs`, `App::with_storage` takes any other — a browser worker will supply OPFS (improvise-i34), and tests use `MemoryFs`. `improvise-io` keeps path-taking wrappers (`save`, `load`, `export_csv`, `parse_csv_with_warnings`) for the native CLI, each delegating to a `_to`/`_from`/`_bytes` variant that takes the backend or the bytes. -3. **Every effect declares an `EffectKind`, and there is no default.** `ViewOnly` never reaches other clients; `Model(CellData | LayoutGeometry | WholeWorkbook)` does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declared `ViewOnly` edit would simply never reach connected clients. Watch two traps: the *View* lives inside `model_state.workbook`, so "touches model_state" does NOT mean "is a model change" (axis/page/format are `LayoutGeometry`, cursor and scroll are `ViewOnly` — see improvise-8uz; and `SwitchView`, `ViewBack` and `ViewForward` moved from `LayoutGeometry` to `ViewOnly` with improvise-jytm, writing `view_state` and nothing else, which is what took the latter two off the wire — but a sound derivation is not a guard: only `SwitchView` had one, and review showed reclassifying either sibling back was silent across the whole suite); and `Save`/`SaveAs`/`MarkDirty` touch `model_state` for bookkeeping only and are `ViewOnly`. `AddCategory`/`RemoveCategory` are the third trap and go the other way: they look like data edits, but both go through `Workbook`, whose `on_category_added`/`on_category_removed` rewrite **every** view's axis map, so they are `LayoutGeometry`. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data *and* view config, so it clears either bar; `structural_effects_declare_their_kind` pins the declaration itself. A third trap, now guarded: **`EffectKind` is too coarse to check a *mapping*.** `ui/reduce.rs::model_effects` sends each of the 26 `ModelCommand` variants (counted on `c1772ca9`; gotcha 38) to one effect, and every inverse pair — `HideItem`/`ShowItem`, `AddItem`/`RemoveItem`, `CreateView`/`DeleteView`, `SetCell`/`ClearCell` — declares the *same* kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, so `every_model_command_names_the_effect_of_the_same_name` asserts that, reading the type name off the effect's derived `Debug` (`Effect` is a trait object, so `type_name` is out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests: `Model::remove_category` also drops every formula targeting the category, which is why `Dirty::of(LayoutGeometry)` reports `formulas: true` — see gotcha 35. +2. **Effects never touch `std::fs`.** Every document read or write goes through `App::storage: Box` (improvise-fuj, closed). Two writes still go around it: `App`'s autosave and the headless final write call the path-taking `persistence::save` (improvise-31oe.11, open). The host picks the backend: `App::new` supplies `PhysicalFs`, `App::with_storage` takes any other — the browser worker supplies OPFS (improvise-i34, closed), and tests use `MemoryFs`. `improvise-io` keeps path-taking wrappers (`save`, `load`, `export_csv`, `parse_csv_with_warnings`) for the native CLI, each delegating to a `_to`/`_from`/`_bytes` variant that takes the backend or the bytes. +3. **Every effect declares an `EffectKind`, and there is no default.** `ViewOnly` never reaches other clients; `Model(CellData | LayoutGeometry | WholeWorkbook)` does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declared `ViewOnly` edit would simply never reach connected clients. Watch two traps: the *View* lives inside `model_state.workbook`, so "touches model_state" does NOT mean "is a model change" (axis/page/format are `LayoutGeometry`, cursor and scroll are `ViewOnly` — see improvise-8uz; and `SwitchView`, `ViewBack` and `ViewForward` moved from `LayoutGeometry` to `ViewOnly` with improvise-jytm, writing `view_state` and nothing else, which is what took the latter two off the wire — but a sound derivation is not a guard: only `SwitchView` had one, and review showed reclassifying either sibling back was silent across the whole suite); and `Save`/`SaveAs` touch `model_state` for bookkeeping only and are `ViewOnly`. `AddCategory`/`RemoveCategory` are the third trap and go the other way: they look like data edits, but both go through `Workbook`, whose `on_category_added`/`on_category_removed` rewrite **every** view's axis map, so they are `LayoutGeometry`. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data *and* view config, so it clears either bar; `structural_effects_declare_their_kind` pins the declaration itself. A third trap, now guarded: **`EffectKind` is too coarse to check a *mapping*.** `ui/reduce.rs::model_effects` sends each of the 26 `ModelCommand` variants (counted on `c1772ca9`; gotcha 38) to one effect, and every inverse pair — `HideItem`/`ShowItem`, `AddItem`/`RemoveItem`, `CreateView`/`DeleteView`, `SetCell`/`ClearCell` — declares the *same* kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, so `every_model_command_names_the_effect_of_the_same_name` asserts that, reading the type name off the effect's derived `Debug` (`Effect` is a trait object, so `type_name` is out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests: `Model::remove_category` also drops every formula targeting the category, which is why `Dirty::of(LayoutGeometry)` reports `formulas: true` — see gotcha 35. 4. **Commands never mutate.** `&CmdContext` is read-only; return `Vec>`. If you want to touch `&mut App`, add or extend an Effect. 5. **`CellKey` is always sorted.** Use the constructors; equality and hashing rely on canonical form. 6. **No `Model::add_item`.** With a `Workbook` in hand use the facade: `wb.add_item("Region", "East")?` / `add_item_in_group` / `remove_item` (improvise-tvt). With only a `Model`, go through the category: `m.category_mut("Region").unwrap().add_item("East")`. @@ -174,15 +175,15 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 12. **Panel keymaps come from `PanelKeymap`.** The three panel modes share a spine (Esc/Tab→Normal, arrows and j/k→`move-panel-cursor `, every panel's toggle key, `:`→command mode) built by `PanelKeymap::new(panel)`; the keys that act on the panel's contents arrive through `.bind`/`.bind_args`/`.delete` because they vary in shape, not just command name (improvise-4pg). `Panel::ALL`/`name()`/`key()` in `ui/app.rs` drive the toggle keys for Normal and all three panels, so a fourth panel reaches every keymap from one `ALL` entry. `name()` must spell what `parse_panel` accepts. 13. **Prefix keys are stored as an address, not a keymap.** `ViewState::transient_prefix` holds the `(KeyCode, KeyModifiers)` that opened a prefix; `KeymapSet::prefix_keymap` resolves it back to the sub-keymap, walking parents so `RecordsNormal` reaches Normal's `g`/`y`/`Z` (improvise-jc8) — that is the command-layer wrapper; the resolution itself is `Keymaps::prefix_keymap` in `improvise-protocol/src/keymap.rs`. `ViewState` must stay free of `improvise-command` types — it is destined for the protocol crate, which `improvise-command` will depend on. Resolution assumes prefixes are **flat**; `prefix_keymaps_are_flat` fails the build if a nested one is added. 14. **Keybindings are per-mode.** `ModeKey::from_app_mode()` resolves the mode; Normal + `search_mode=true` maps to `SearchMode`. Minibuffer modes bind Enter/Esc as `Binding::Sequence` so `clear-buffer` fires alongside commit/cancel. Hints are checked against the keymaps: `every_hint_key_resolves_in_its_mode` (`command/keymap.rs`) splits each `AppMode::hint_text()` and requires every key it names to resolve in that mode's map, because `only_quit_has_no_hint` asserts only that a hint is *present*. It checks which keys are named, not what they do. Its tokenizer knows the hint dialect explicitly (named keys, `Ctrl-`, `F/C/V` alternatives, character runs like `hjkl`/`[:]`, the em-dash prose tail, CommandMode's `:q` ex-commands) and panics on a token it cannot classify rather than skipping it. -15. **Registry names come from prototypes, not strings.** Every `register*` call reads the name off a prototype `Cmd` instance, so a typo is impossible (improvise-9cn) — there is no name literal in `registry.rs`. What types can't reach: four commands name themselves off a field (`ViewNavigate`, `TileAxisOp`, `ToggleGroupAtCursor`, `CommitAndAdvance`), so a prototype can name the wrong *variant* and still compile. That shows up as a **duplicate** registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes. `registered_names_are_unique` catches it; `every_bound_command_name_is_registered` catches the keymap side, and checks **both** halves of `dispatch`'s `registry.interactive(name, args, ctx).ok()?` — the name resolves *and* the args parse — because improvise-s13 moved `enter-mode ["export-prompt"]`/`["formula-edit"]` out of the nullary class, where a typo'd mode name compiles into a dead key. It builds the `CmdContext` in each keymap's own `AppMode`, since `commit-buffer item` reads `ctx.mode`. -16. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic. Its counterpart on the effect side is `simple_effect!` in `ui/effect.rs`, which declares a one-field newtype that writes one place on the `App`. Its **kind argument is mandatory** — `simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)` — even though every call site so far answers `ViewOnly`: hard-coding it reintroduced exactly the default gotcha 3 does without, since `$place` is an unconstrained expression and a one-line `active_view_mut().number_format` setter would have reported `ViewOnly` with the suite green (improvise-8fy). Likewise, mode entry has no per-mode shim commands — bind `enter-mode ` (improvise-s13); the name is decoded by `parse_mode_name` in `registry.rs`, and `EnterMode` clears the mode's minibuffer on the way in. +15. **Registry names come from prototypes, not strings.** Every `register*` call reads the name off a prototype `Cmd` instance, so a typo is impossible (improvise-9cn) — so no `register*` call spells a name. Name literals do remain in `registry.rs`, and only tests guard them — the keymap test `every_bound_command_name_is_registered` for the `Move` literals (all bound; `registered_names_are_unique` covers them too), and for the four aliases `registry.rs::every_alias_resolves_to_a_registered_command` (a hand-copied list, not the table) plus the `parse.rs` tests `alias_add_cat_resolves_to_add_category`, `alias_formula_resolves_to_add_formula`, `alias_add_view_resolves_to_create_view` and `alias_q_bang_resolves_to_force_quit` — the `Move` prototypes carry `cmd_name: "move-selection"` / `"scroll-rows"` / `"page-scroll"` (the four `jump-*` names go through `reg_jump!`), and four `r.alias` calls spell an alias and its target (`add-cat`, `formula`, `add-view`, `q!`). What types can't reach: five commands name themselves off a field (`Move` off `cmd_name`, `ViewNavigate`, `TileAxisOp`, `ToggleGroupAtCursor`, `CommitAndAdvance`), so a prototype can name the wrong *variant* and still compile. That shows up as a **duplicate** registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes. `registered_names_are_unique` catches it; `every_bound_command_name_is_registered` catches the keymap side, and checks **both** halves of `dispatch`'s `registry.interactive(name, args, ctx).ok()?` — the name resolves *and* the args parse — because improvise-s13 moved `enter-mode ["export-prompt"]`/`["formula-edit"]` out of the nullary class, where a typo'd mode name compiles into a dead key. It builds the `CmdContext` in each keymap's own `AppMode`, since `commit-buffer item` reads `ctx.mode`. +16. **`effect_cmd!` macro** wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written `Cmd` once there's real decision logic. Its counterpart on the effect side is `simple_effect!` in `ui/effect.rs`, which declares a one-field newtype that writes one place on the `App`. Its **kind argument is mandatory** — `simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly)` — even though every call site so far answers `ViewOnly`: hard-coding it reintroduced exactly the default gotcha 3 does without, since `$place` is an unconstrained expression and a one-line `active_view_mut().number_format` setter would have reported `ViewOnly` with the suite green (improvise-8fy). Exactly six wrappers return `unless_unknown(needs, effect)` rather than the bare effect (gotcha 51): `set-axis`, `set-page`, `toggle-group`, `hide-item`, `show-item` and `switch-view`. The rest return bare effects — `delete-view` (whose `DeleteView` effect refuses an unknown view itself), `add-item`, `add-items`, `set-cell`, `create-view` — and `remove-formula` checks in `execute`. Likewise, mode entry has no per-mode shim commands — bind `enter-mode ` (improvise-s13); the name is decoded by `parse_mode_name` in `registry.rs`, and `EnterMode` clears the mode's minibuffer on the way in. 17. **Float equality inside formula eval has one epsilon, and the div/0 guard shares it.** `FLOAT_EQ_EPSILON = 1e-10` in `model/types.rs` is used by `=`/`!=` **and** by the division-by-zero guard (`rv.abs() < FLOAT_EQ_EPSILON`), in both `eval_expr` and its aggregated twin — six uses across the two evaluators (a div/0 guard plus the `Eq` and `Ne` arms in each), one constant. This gotcha previously recorded the opposite (`=`/`!=` epsilon, div/0 exact) and was stale; the mix is gone. Why an epsilon at all and why this one: `context/code/core.md` §4. -18. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, do `DataStore::matching_cells`/`matching_values`: the secondary index **narrows** the candidates, then `matching_positions` resolves each to its position in the primary `IndexMap`, sorts, and reads back. **An index whose values are `HashSet`s is for O(1) lookup, never for iteration.** Walking it gave a different order in every process, which was not tidiness. Records-mode rows reordered between sessions; `SUM`/`AVG` drifted in the last bits because `f64` addition is not associative (`Min`/`Max`/`Count` and the text-unanimity rule of gotcha 7 are order-free, so they were never affected); and worst, the **drill snapshot** froze its record list in hash order while `drill_state.pending_edits` are keyed by *row index* (gotcha 1), so the same drill opened in a second process addressed **different records** — silent misdirected writes, and nothing in the tracker knew. Measured at 18,000 cells: +20% worst case on `matching_values`, and `matching_cells` 7% *faster*, sorted positions resolving `CellKey`s with better locality. `DataStore::live_records` was the open choice and is now decided (improvise-2yc2): it walks `cells` and projects insertion order onto records, deliberately paying O(cells) rather than O(records), because `cells`' order is already the answer `matching_positions` gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on `RecordEntry`, since a stored position is a copy and a record deleted and re-created would hold its old one. Note what is *not* on offer: an unordered return — every iterator has an order, and a type that merely disclaims one still hands the caller hash order, which is the guard living one level away from the hazard and the shape this whole class of bug returns through. **The second real instance was `import::analyzer::analyze_records`** (improvise-7yx), which built its distinct values through a `HashSet`: `build_model` adds items in exactly that order and `IndexMap` preserves it, so the *same CSV imported twice* produced two different `.improv` files and two different row orders. Now first-seen order, pinned by a five-iteration test because each `HashSet` gets its own `RandomState`. Also answered here since: an **empty `partial`** matches every cell vacuously (improvise-hkt6) — it used to index `sets[0]` and panic, unreachable only because both callers guarded. **A group's fold reads through the same rule.** `Model::fold_stored` folds a collapsed group's stored values once, over every record the group covers, through `DataStore::spanned_values` → `spanned_positions` (improvise-yb4b) — one scan, not a lookup per member. `spanned_positions` chooses **by index-set size** between narrowing once by the base key and narrowing once per covered item of the smallest span, whichever visits fewer (improvise-jq0m), and **both arms return store order** — sorted positions, as `matching_positions` does. That is load-bearing, not tidy: a fold that returned hash or per-item order would reintroduce the `SUM` drift above, and the choice between the arms would then be visible in the last bits of a total. +18. **`IndexMap`** preserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, do `DataStore::matching_cells`/`matching_values`: the secondary index **narrows** the candidates, then `matching_positions` resolves each to its position in the primary `IndexMap`, sorts, and reads back. **An index whose values are `HashSet`s is for O(1) lookup, never for iteration.** Walking it gave a different order in every process, which was not tidiness. Records-mode rows reordered between sessions; `SUM`/`AVG` drifted in the last bits because `f64` addition is not associative (`Min`/`Max`/`Count` and the text-unanimity rule of gotcha 7 are order-free, so they were never affected); and worst, the **drill snapshot** froze its record list in hash order while `drill_state.pending_edits` are keyed by *row index* (gotcha 1), so the same drill opened in a second process addressed **different records** — silent misdirected writes, and nothing in the tracker knew. Measured at 18,000 cells: +20% worst case on `matching_values`, and `matching_cells` 7% *faster*, sorted positions resolving `CellKey`s with better locality. `DataStore::live_records` was the open choice and is now decided (improvise-2yc2): it walks `cells` and projects insertion order onto records, deliberately paying O(cells) rather than O(records), because `cells`' order is already the answer `matching_positions` gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on `RecordEntry`, since a stored position is a copy and a record deleted and re-created would hold its old one. Note what is *not* on offer: an unordered return — every iterator has an order, and a type that merely disclaims one still hands the caller hash order, which is the guard living one level away from the hazard and the shape this whole class of bug returns through. **The second real instance was `import::analyzer::analyze_records`** (improvise-7yx), which built its distinct values through a `HashSet`: `build_model` adds items in exactly that order and `IndexMap` preserves it, so the *same CSV imported twice* produced two different `.improv` files and two different row orders. Now first-seen order, pinned by a five-iteration test because each `HashSet` gets its own `RandomState`. Also answered here since: an **empty `partial`** matches every cell vacuously (improvise-hkt6) — it used to index `sets[0]` and panic, unreachable only because both callers guarded. **A group's fold reads through the same rule.** `Model::fold_stored` folds a collapsed group's stored values once, over every record the group covers, through `DataStore::spanned_values` → `spanned_positions` (improvise-yb4b) — one scan, not a lookup per member. `spanned_positions` chooses **by index-set size** between narrowing once by the base key and narrowing once per covered item of the smallest span, whichever visits fewer (improvise-jq0m), and **both arms return store order** — sorted positions, as `matching_positions` does. That is load-bearing, not tidy: a fold that returned hash or per-item order would reintroduce the `SUM` drift above, and the choice between the arms would then be visible in the last bits of a total. **A partial as wide as the widest stored cell needs no narrowing** (improvise-felo.13): `DataStore` keeps `arities` (how many stored cells have each width), and `exact_positions` answers a strictly-sorted partial at least that wide with one probe of `cells`, since only an equal cell can match; anything else falls through to the index. The answer is the same sorted positions. 19. **Commit paths for cell edits** split across `commit_cell_value` → `commit_regular_cell_value` / `commit_plain_records_edit` in `command/cmd/commit.rs`. Keep the synthetic-records branch in sync with the plain branch when changing behavior. **Which cells refuse an editor is one function, asked at both ends of an edit**: `cmd::core::uneditable(model, layout, row, col)` is the shared decline for **opening** an editor (`EnterEditAtCursor`) and for **committing** one (`CmdContext::committable_cell_key`) — a fold or other aggregate, a formula's result, or a `CellId::NonItem` (improvise-enz6, improvise-gk51). It reads the cell's `CellId` through `project::cell_id`, the same derivation the projection uses, and the browser's `resolve::declined_at` matches on the projected `CellId`, so the two front ends refuse the same cells. Asking only at open is the bug enz6 was: Enter-to-advance moved onto a collapsed group's row and committed a write to the group coordinate. **`CellId::NonItem { category, item }` is a stored value whose coordinate names no item of its category** — records mode can draw one, and no write can address it, so it is projected as its own variant rather than as a `Record` that the host then refuses (gk51 was exactly that mismatch). Its one wording is `improvise_protocol::non_item_refusal`. 20. **Minibuffer commits go through one command.** `commit-buffer ` (`CommitBuffer { kind: CommitKind }`) owns buffer→trim→bail-on-empty→dirty→status for all three; the varying parts are methods on `CommitKind`. The add effect is emitted **last** so its failure message wins over the command's optimistic status (improvise-6t3). `CommitKind::Item` carries its target category, resolved from `AppMode::ItemAdd` by `parse_commit_kind` at registry time — so `commit-buffer item` is not constructible from a script line. 21. **An abort stops the chain, never the bookkeeping — and ordering is what separates them.** Three effects set `abort_effects`, and they are not all saves: `Save` and `SaveAs` on a failed write or a missing path (improvise-8tk — `SaveAs` was the arm left out of the original fix, so `:w /bad/path . q` quit on a failed write), and `AddCategory` when `Model::add_category` refuses, because what follows assumes the category exists — from the `+cat` column (improvise-2zq) the rest of the batch would advance the cursor and re-open an editor on a column that was never created. A `Binding::Sequence` flattens into one effect vec, so the abort carries across sequence steps; that is a property of the *flattening*, and it is why improvise-0hb's proposal to apply effects between steps was rejected — `apply_effects` resets the flag per batch, so step 2 in a fresh batch would land the `Quit` after a failed `Save`, silently reopening a P1. `a_sequence_whose_first_step_aborts_does_not_run_the_second` is the guard. **The rule the aborts taught, which is the part to carry:** an abort at the *tail* of a batch also ate the mode restoration and the buffer clearing, stranding the user in `CommandMode`/`Editing`/`SavePrompt` — where `draw_bottom_bar` renders the minibuffer *instead of* the status line, so the error the effect had just written was never drawn and the operation appeared to do nothing at all. So **tidy-up goes at the head, where an abort cannot reach it**: `ExecuteCommand` fronts its batch with `ReturnToNormalUnlessMoved`, `CommitAndAdvance` with the mode change, `CommitSave` with the mode change (found by Copilot on PR #27). Ordering answers it without giving any effect a "runs anyway" flag, and it does not weaken gotcha 31's observed-not-predicted mode settling: whatever a later effect sets, it sets *after*, so the mode a `:` line ends in is unchanged in every case — the only difference is what happens when the batch never reaches the end. **An abort is not a rollback**; that is the hole clause 4 leaves (gotcha 47). -22. **The import wizard's back path is a history stack, not a reverse table.** `advance` pushes the step it leaves onto `ImportWizard::history`; `previous` pops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live in `validation_error()`, checked on the step where the user can still fix them — never deferred to `build_model`. `NameModel` is terminal for `advance`: Enter there calls `build()`, which parks the workbook in `built` on `Done`, and `AcceptWizardModel` takes it from there. Going back from `Done` discards `built`. -23. **`app.layout` goes stale mid-batch.** `apply_effects` rebuilds only *after* the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs a `RebuildLayout` effect in front of it — the command decides the order (see `EnterEditAtCursorCmd`, `CommitAndAdvance`). Read the cursor's cell text via `App::display_value_at_cursor()`; don't build a `CmdContext` inside `apply` (it recomputes column widths). +22. **The import wizard's back path is a history stack, not a reverse table.** `advance` pushes the step it leaves onto `ImportWizard::history`; `previous` pops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live in `validation_error()`, checked on the step where the user can still fix them — never deferred to `build_model`. `NameModel` is terminal for `advance`: Enter there calls `build()`, which parks the workbook in `built` on `Done`, and `AcceptWizardModel` takes it from there. Going back from `Done` discards `built`. **`ResolveDuplicates` appears only when `ImportPipeline::collisions()` is non-empty**: `after_fields` routes there from `ReviewProposals` or `ConfigureDates`, so it comes after the field and date steps and before `DefineFormulas`, with `NameDuplicateCategory` between them when the answer is Separate. Enter there is `WizardOp::ChooseDuplicate` (sum, keep separate, or go back). `ImportPipeline::on_duplicate` defaults to `DuplicatePolicy::Refuse`, so a path that never asked — the headless `--no-wizard` import included — errors listing the collisions rather than summing them. **A collision is a cell written by two records** (a tuple *and* a measure), not a shared tuple: records with disjoint measures on one tuple write different cells and collide with nothing (improvise-9bit). Reasoning: `context/code/formula-and-io.md`, "Colliding records are a question, not a default". +23. **`app.layout` goes stale mid-batch.** `apply_effects` rebuilds only *after* the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs a `RebuildLayout` effect in front of it — the command decides the order (see `EnterEditAtCursorCmd`, `CommitAndAdvance`). Read the cursor's cell text via `App::display_value_at_cursor()`; don't build a `CmdContext` inside `apply` (it reads `layout`, the memoised `grid_geometry()` and the cursor, and a stale layout there is the bug). **Since improvise-felo.13 the rebuild is skipped when nothing it reads has moved**: `App::refresh_layout` rebuilds only when `layout_stale` (any `EffectKind::Model(_)` in the batch, or `install_workbook`), the drawn view's name, or the drill snapshot's pointer (`LayoutBasis`) changed, so a cursor key rebuilds nothing, and the column widths and row-header width are measured once per layout (`App::grid_geometry`, dropped with the layout). Two of the three inputs are **observed** (the view name and the drill pointer); `layout_stale` is **declared** from `EffectKind::Model(_)` (`touches_model`), so gotcha 3's classification hole — a `ViewOnly` effect that changes what the layout reads — now also leaves `app.layout` stale, where before felo.13 every batch rebuilt. It is still not a hand-kept list of effects. A writer that bypasses effects and changes what the layout reads must call `rebuild_layout()` itself. Measurements: `context/test-evidence/layout-cost.md`. 24. **The cursor is per-client and keyed by view.** `ViewState::cursors: HashMap` holds `selected`/`row_offset`/`col_offset`, keyed by view name; an absent key means the origin. Reach it through `App::cursor()` (a `Copy` snapshot), `App::cursor_mut()`, and `App::reset_cursor()` — never off `View`, which no longer has the fields (improvise-8uz). Two consequences. **Changing the grid's shape must reset the cursor explicitly**: `View::transpose_axes`/`cycle_axis` used to do it themselves, and now `TransposeAxes`/`CycleAxis` call `app.reset_cursor()` — a new geometry effect that renumbers rows or columns has to do the same. **Replacing the document must clear the map**: `LoadModel` and `AcceptWizardModel` call `cursors.clear()`, because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replaces `Workbook::normalize_view_state`, which is gone — a `View` can no longer carry a stale offset. The same clearing owes the **session mirrors**: `install_workbook` bumps `App::document_generation`, and `ClientSession::project` reseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reach `Sessions`, so the generation is the mechanism and `Sessions::rebase` is only the client-facing half. The same is true of a **geometry** change, and it owes the mirrors *both halves*: `App::cursor_epoch` is bumped by `reset_cursor` and watched by `Sessions::adopt_cursor_epoch`, which resets every mirror **and** returns `Broadcast::ToAll(Command::ResetCursor)` (improvise-gvnb). Doing only the first is **strictly worse than doing nothing** — the diff then believes every client already holds origin-positioned content and sends nothing, so they stay stale *permanently* rather than until their next scroll. The trigger is the **epoch moving**, not the command variant, for the reason gotcha 39 watches `document_generation` instead of matching on `LoadModel`: which effects renumber the grid is a per-effect decision, so a table of variants would be a second description of it, free to disagree. What is broadcast is an **invalidation, never a position** — another client's transpose must not move where I am looking (that is UI and it is mine, improvise-g0f), only declare that where I was looking has stopped meaning anything. **Since improvise-ni6k no effect calls `reset_cursor` at all — the cursor is invalidated by OBSERVING the grid's frame.** `apply_effects` diffs the frame after every model effect, **inside the loop**, because a batch that reframes and then places the cursor loses the placement otherwise (four records-mode tests pin that position). There is no list of effects anywhere, so the mechanism is total over `EffectKind::Model(_)` by construction — a genuinely stronger shape than a hand-kept table, and the reason the old "a geometry effect that forgets `reset_cursor` is invisible in both directions" hazard is closed. @@ -192,7 +193,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 25. **`ViewState` and its field types are `Serialize`/`Deserialize`.** It is the wire payload of the client Snapshot (improvise-q08), so `AppMode`, `MinibufferConfig`, `MinibufColor`, `ViewFrame`, `KeyCode` and `KeyModifiers` all derive both (improvise-a2x), and `improvise-command` depends on `serde` for it. The constraint this puts on new state: **no borrowed-`'static` fields anywhere ViewState can reach** — `Deserialize` cannot produce one, and the error surfaces on every enclosing variant rather than at the offending field. `view_state_survives_a_json_round_trip` guards it. `ViewState` and `Snapshot` also derive `Clone` and `PartialEq`, so the bootstrap message can be compared in a round-trip test. -26. **Axis presentation lives on `Axis`, colors resolve in `draw.rs`.** Four vocabularies hang off the enum in `view/axis.rs` — `plain_label()` ("Row"/"Col"/"Page"/"None"), `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", *not* a truncation), and `initial()` (one letter for the view panel; `None` is empty). Plus `color() -> AxisColor` and `next()` for the Row→Column→Page→None rotation. `AxisColor` is a semantic palette: `improvise-core` must not name a ratatui type, so `draw.rs::axis_fg` does the one mapping to a terminal color, exactly as `minibuf_fg` does for `MinibufColor` (improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here: `persistence/format.rs` maps `Axis` to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer. +26. **Axis presentation lives on `Axis`; its colour is a `Face`.** Four vocabularies hang off the enum in `view/axis.rs` — `plain_label()` ("Row"/"Col"/"Page"/"None"), `glyph()` (which `Display` appends, giving "Row ↕"), `short_label()` (three columns for the tile bar; `None` is "·", *not* a truncation), and `initial()` (one letter for the view panel; `None` is empty). Plus `next()` for the Row→Column→Page→None rotation. **`Axis` carries no colour**: `AxisColor` and `Axis::color()` are gone. A colour is a `Face::Axis(axis)` in `improvise-protocol/src/face.rs` (ADR 004, `context/code/protocol.md` §10), resolved by the `Theme`, and `draw.rs::face_style` is the one place a face becomes a ratatui `Style` — the tile bar and the category panel both call it, so neither keeps a table. `improvise-core` still names no ratatui type. Not covered here: `persistence/format.rs` maps `Axis` to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer. 27. **`Axis::Filter` constrains without being navigable.** Drilling fixes its coordinates on `Filter`, not `Page` (improvise-dz8): both restrict which records are visible, but only `Page` is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: **"what data is visible" is `View::filtering_cats()`** (Page ∪ Filter — used by `GridLayout` and by `add-record-row` when it builds a key from the current constraints), while **"what can the user act on" is `categories_on(Axis::Page)`** (used by `[`/`]` page cycling) or `View::tileable_cats()` (used by all three tile-select sites). `Axis::next()` maps `Filter` to itself and no navigable axis rotates into it, so tile select cannot hand one out. The `.improv` grammar accepts `filter` and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted `_Drill` view, but the alternative was writing it out as some other axis. **What a virtual view must *be* is model-side and has one home** (improvise-gd28): `view::{records_spine, records_steps, drill_steps}` return `Vec` — pure data naming no `Effect`, no `App` and no front end — and `cmd/grid.rs::steps_into_active_view` is the whole of *how the TUI gets there*, twenty lines a browser answers its own way. Before the split, the drill's filter placement and the records spine were spelled out twice in `cmd/grid.rs` with nothing keeping them in agreement. `DrillState` deliberately did **not** move: it holds an `Rc` and its `pending_edits` are uncommitted writes, so gotcha 30's session-local tag already places it. Reasoning: `context/code/core.md` §11. @@ -221,7 +222,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 39. **`Applied` is an enum because a document swap is not a diff.** `Sessions::apply_checked` returns `Applied::Diff { dirty, broadcast }`, `Applied::Rebased { snapshots }` or — since ADR 001 layer 1 — `Applied::Rejected { reason }`, and the enum *is* the mechanism: a host that wants the `Dirty` must match, and matching names the case where a `Snapshot` is owed. The server's mirrors self-heal — `ClientSession::project` reseeds on the `document_generation` bump — but `ViewState::cursors` lives on the **client**, keyed by the outgoing document's view names (gotcha 24), and only a fresh `Snapshot` replaces it. `Command::Model` put the three `WholeWorkbook` mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. `apply_model` therefore calls `rebase` itself rather than instructing the host to, exactly as `apply_switch` adopts the accepted view itself. **The trigger is the generation moving, not the command variant**: a `LoadModel` that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a *failed* load — `a_load_that_fails_is_not_a_rebase` guards that arm. A `rebase: bool` beside `dirty` was rejected, because the host that forgets to call `rebase` is the host that forgets to read the flag. The **third** case is a *normal outcome*, not an error, and it is in the enum for the same reason the other two are: a host that wants the `Dirty` must match, and matching names it. `ServerMsg::Rejected` is the matching frame, addressed to **the asker alone** — nothing happened, so nobody else is owed anything — and the client keeps it as a `Rejection` rather than a sentence, because force needs to know *which* record and *which* kind (gotcha 43). **Fixture warning.** When testing a document swap, the incoming document's active view must have the **same name** as the outgoing one. A rename makes `apply_model` broadcast a `SwitchView`; the client folds it, and the stale cursor is then read under a key nothing has written — right *by accident*, with the bug invisible. `other_document()` renames, and a first version of this test passed because of it; `a_document_shaped_differently_under_the_same_view_name()` keeps the name and changes only the shape, so there is nothing to hide behind. -40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per *view shape*) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. **Only the `Dirty` is read off the `EffectKind`; both lower tiers are *observed* around the loop** — the source says so at `apply_effects` and this paragraph said the opposite until improvise-ilvl's audit. The `EffectKind` decides only *whether to look*: `touches_model` arms the shape snapshot, `writes_cells` the record fingerprints. `record_versions` **must** be observed, because an `EffectKind` says *that* a batch writes cells, never *which*; `layout_version` is observed for a better reason than necessity — `observe_reshape` diffs `view_shapes` before and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a **fact about the document** rather than a claim about the batch. Two consequences the declared form did not have. An **idempotent** `set-axis` bumps nothing, and batching two real changes into one line or two gives the same `2`. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once **after** the loop is correct where accumulating `Dirty` inside it is. Note the arming is deliberately wider than the tier: a `CellData`-only batch that prunes a row out of a view **does** bump `layout_version`, because the shape moved. Three further consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run. +40. **A batch's basis is three tiers, and only two of them can be read off the effect.** `App` carries `document_generation` (bumped by `install_workbook`), `layout_version` (per *view shape*) and `record_versions` (per *record*) — ADR 001's stale-basis check, and shared document state, so never on `ViewState` (gotcha 30). **The unit is the record**: `record_of(key)` is the coordinate tuple minus `_Measure` (`record_row_identity`), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. **Only the `Dirty` is read off the `EffectKind`; both lower tiers are *observed* around the loop** — the source says so at `apply_effects` and this paragraph said the opposite until improvise-ilvl's audit. The `EffectKind` decides only *whether to look*: `touches_model` arms the shape snapshot, `writes_cells` the record fingerprints. `record_versions` **must** be observed, because an `EffectKind` says *that* a batch writes cells, never *which*; `layout_version` is observed for a better reason than necessity — `observe_reshape` diffs `view_shapes` before and after and bumps only where a view's shape actually moved (improvise-pdvm/nynk), so it is a **fact about the document** rather than a claim about the batch. Two consequences the declared form did not have. An **idempotent** `set-axis` bumps nothing, and batching two real changes into one line or two gives the same `2`. And the gotcha 21 argument is subsumed rather than restated: an aborted batch moved no shape, so there is nothing to not-claim — which is why observing once **after** the loop is correct where accumulating `Dirty` inside it is. **`view_shape` hashes what is drawn, not occupancy** (its doc comment: "Drawn, not configured") — axes, the drawn item names minus hidden ones, folds, page selections and the `prune_empty` *flag* — so an occupancy write does **not** bump `layout_version`: a `CellData`-only batch that prunes a row out of a view moves the cursor epoch (the frame observation) and not `layout_version`, as `context/code/command.md` records (the question that raised was improvise-6nsd, closed). The arming is wider than the tier (`touches_model` takes the shape snapshot), but a bump needs a shape hash to have moved. Three further consequences. A write landing the value already there **bumps nothing**, which is correct — no basis was invalidated. A `LayoutGeometry` effect that drops cells **will** bump their records when it shares a batch with a `CellData` one: an over-bump costing at most an unnecessary rejection, the same direction `Dirty::of` is deliberately sloppy in. And only *raw* data is fingerprinted — formula results live in `Model::formula_cache`, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a **set**: chaining `before.keys()` with `after.keys()` counts any rewritten record twice, a bug that survived one test run. 41. **`Storage` is not a VFS, and that closes the SQLite question.** The trait is whole-document `read`/`write` bytes (gotcha 2, `invariant(storage_is_two_ops)`) — no offsets, seeking, handles or locking — so an `sqlite3_vfs`, which needs `xRead(offset,len)`/`xWrite`/`xTruncate`/`xLock`, has nothing to bind to. SQLite *can* still sit behind it, as an in-memory database serialized to a blob (`rusqlite` `serialize`/`deserialize_read_exact`, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is **22% larger** than `.improv`, **4.7× larger gzipped**, ~**160×** larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. **A second serialization format is a second implementation of the grammar**, obliged to track `improv.pest` forever; and **its own round-trip test cannot detect drift from `.improv`**, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way `export_sqlite` beside `export_csv`, native-only and feature-gated: `libsqlite3-sys`' C build fails under this repo's nix cc-wrapper for `wasm32-unknown-unknown`, which would break the `wasm` CI gate the day it landed. @@ -235,13 +236,13 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text 43. **A batch carries its basis, and the server had to publish it first.** `Command::Model { basis, cmd }` is a **struct variant** so the basis cannot be forgotten, and `Sessions::apply_model` checks it *before* applying anything — a stale batch is never applied, not even partially (improvise-xwi6/fkfg, ADR 001 layer 1). Four things to know. **`Basis` has no `Default` and no public constructor**: a zero-valued basis is not "no claim", it is the claim that *the document is fresh*, which in a new `App` is true — so a default would be a forgeable bypass rather than an absence. `BasisCache::claim` is the only door a client has and it returns `None` until the server has published one. Note what that does and does not buy: `Basis` derives `Deserialize` because it must cross the wire, and its fields are public, so **a hand-rolled client can state any tiers it likes**. This is an *anti-staleness coordination check, not a security boundary* — `RecordUnstated` closes **omission**, not **invention**, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. "The only constructor" is a statement about clients written against this crate, which is still worth having: it makes the honest path the easy one. **Which records are checked is the server's decision** — `reduce.rs::records_named`, an exhaustive match with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. Reading the list off the *client's* claim would be a **force by omission**: the record tier skipped, with no flag to remove and nothing in the message saying so. A claim that omits a record the command writes gets `Rejection::RecordUnstated`, a case of its own because "you lost a race" and "you were never in one" are different things to tell a user. **`Rejection` keeps structural and data staleness apart**, which is the whole reason ADR 002 put a maintained `RecordHash` index in `DataStore`: `RecordGone` means nothing resolves and there is nothing to force (**force never creates structure**), `RecordMoved` means it resolves at another version and force is one keystroke — which is also why the wasm client stores the `Rejection` itself rather than collapsing it into `status_msg`, since a string cannot carry which record or which kind. **And the ADR omitted the publication.** It said a batch carries the basis it was composed against and never said how a client learns one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging. `Projection::Basis` + `BasisCache` close that — the server publishes the two document tiers plus the counters for the records *this client's grid names*, diffed against what that client was last told and seeded in the bootstrap — and it is emitted **after** the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown, the mirror image of gotcha 33. That ordering was documented as load-bearing in three places and **held up only by the order of two statements** until `a_published_basis_follows_the_grid_updates_it_describes` pinned it (proved by injecting the swap) — the same job `a_cell_update_that_overtakes_its_labels_is_lost` does for the mirror-image rule. Two things are unchecked and deliberately so: `Command::SwitchView` carries no basis (it sits outside `Command::Model`; the workbook refuses a view it does not have, so it cannot corrupt, but it is a hole in the rule), and `apply_command` does not check at all — **the check is a property of the session door, not of the mutation path**, which is right because a host holding the `App` is the truth rather than a claimant. That second one **was** a door rather than an exemption, and needed a type to stop being one (improvise-ondj): both took a wire `Command` and their `Command::Model { cmd, .. }` arm **discarded the basis with `..`**, while `ui/reduce.rs`'s own module doc pointed every future host — the browser worker included — at exactly those two names. **The struct variant closed omission *from the message*; it did nothing to close ignoring *on arrival*, which is the half a struct variant is usually reached for.** So both now take `Checked<'_>`, a private-field newtype over a `&Command` whose only constructor is `Checked::against(&App, &Command) -> Result<_, Rejection>` — also the only caller of `stale_basis`. `Sessions::apply_checked` receives the proof and `Checked::against` is answered **once, before the dispatch**, so `apply_switch`/`apply_model` receive the proof rather than each remembering to ask: an invariant made unrepresentable rather than validated, the same answer `prune_axis_items` and `gp-tree`'s `Row` got. Visibility was the weaker option and was rejected for a concrete reason — `pub(crate)` closes only the out-of-crate path, and `reduce_full` had **no production caller anywhere in the workspace**, so it would have become dead code. It has since been **deleted** — which is the argument’s own conclusion arriving, not a contradiction of it; the surviving sibling is `reduce_key`. This paragraph named it in the present tense for some time after, so read every function name here as a claim to check. **The regression guard is therefore the signature**: the test that reproduced the bug no longer compiles, and what remains asserts the constructor refuses and that refusing leaves the document untouched. One residual hole, documented on the type and filed: a `Checked` does not name *which* `App` it was checked against, so a multi-document host could in principle check one and apply to another. That is also why `ui::reduce::caught_up(&App, cmd)` is not a bypass: it takes `&App`, so nothing reachable from a wire can call it. **Since improvise-955n the frame, not the command, is what is answered — and only two of the three tiers hoist.** `CheckedBatch::against` runs before anything in a `ClientFrame::Commands` is applied. `document_generation` and `layout_version` describe the *document*, so answering them once for the frame is exactly right, and a first version hoisted all three — which was a lost update: the record tier asks "is the client's picture of **this record** current", and which records are at stake is a property of the *command* (`records_named`). Skipping it for later commands let `[SetCell(North), SetCell(South)]` overwrite a South somebody else had moved, silently, with the frame reported accepted, and invisible to the other two tiers because a `SetCell` is `CellData` and moves neither. So every mutation answers for its own records **against the frame's basis** — the one the first basis-carrying command stated, never each command's own field, or a later command could claim a fresher picture than the frame was composed under. The one exemption is stated over a *property* rather than a list of commands: a record that **neither resolves in the document nor is claimed by the basis** is one the frame is about to create (`[AddItem(East), SetCell(East)]`, which `BasisCache::claim` cannot possibly claim for), and only that combination is exempt; a record that does not resolve but *is* claimed is still `RecordGone`. That the `Basis` inside a *later* `Command::Model` is ignored rather than compared is an honest interim: clause 1 says the **frame** states one basis, and moving the field from `Command::Model` to `ClientFrame::Commands` would make that unrepresentable-otherwise instead of a convention — a protocol change that belongs with clause 4. Two things worth keeping from the rejected design: **arity is not a property of a variant** (a future `Command::RunScript` is one variant performing unboundedly many mutations), so no count over the vocabulary can enforce "one mutation per frame"; and **the transaction boundary is not the frame**, because one command may already be a chain, which is why clause 4's mechanism belongs at `App::apply_effects`. -44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers and `GroupEnd { level }` markers with `Item { tuple, collapsed }` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**, and since improvise-e265 membership is **the innermost open run**: `FrameGroup` and `FrameEntry::GroupEnd` each carry a `level` (an index into `cats`), runs **nest**, a header at level L replaces L and closes every deeper run, and a `GroupEnd { level }` closes L and deeper. Before e265 it was the nearest preceding header unless a level-less `GroupEnd` intervened (improvise-gd83, PR #77), which could not express one run inside another. `GroupEnd` draws nothing and exists only so membership is not inherited. `AxisFrame` deserialises through `UncheckedAxisFrame` and **refuses** a `level >= cats.len()` — a level naming no category is a malformed frame, not a clamp. And `FrameEntry::Item` is `{ tuple, collapsed }`: `collapsed` is the **layout's own fold flag**, read by `AxisFrame::folds`, so a client asks the frame whether a position folds rather than re-deriving it from the groups (improvise-4k1j). Both of the gaps this sentence used to list are **closed**, and it named them as open for a day after: `group_runs` coalesces by **header identity** (`std::ptr::eq`), not by name, so two runs under two headers equal as values are two runs (improvise-h0iv); and `AxisFrame::first` carries **no** `serde(default)` — its doc comment says why, since zero is a claim and not an absence (improvise-1wl0). `doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)` is about exactly this paragraph, twice now. And `project::grid_of` evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot. +44. **The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too.** `GridWidget` holds no `&Model` and no `&View`: cell text, aggregate-ness (`CellId::Aggregate`, ADR 002), column widths, the page bar, the totals strip and now the **frame** all come off `GridCache`. The TUI still builds that cache over the **whole** grid (offsets zero), so a cache position is an absolute position — the browser's `Viewport` is a real window, the TUI's is not, and the two clients therefore use it differently; column totals **must** be projected rather than client-derived, because the sum runs over every row *including the ones a viewport scrolled past*. The frame is an `AxisFrame` per axis — `cats` (the categories, outermost first) plus `entries: Vec` interleaving `Group(FrameGroup)` headers and `GroupEnd { level }` markers with `Item { tuple, collapsed }` label *tuples*, exactly as `GridLayout::row_items` interleaves them. That replaced a `Vec` built by `GridLayout::row_label`, which slash-joined the tuple — the exact rendering `two_row_categories_produce_cross_product_labels` says must not appear — so the browser drew `Food/Alice` where the TUI drew two header columns, with no groups and no collapse indicators. It is **one** type and not a `labels` plus a `levels` beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a *type* change rather than an addition. Three things to know before touching it. **`leaf_labels()` is not "the labels"** — it is the innermost label, what a one-line summary calls a row by; a draw loop wants `header_labels(first_rendered)`, `groups()` and `shares_ancestry(sel)`, each of which is one pass, because `item`/`label`/`shows_level`/`group_of` scan `entries` from the start and asking them per position is quadratic in the frame *and grows with the TUI's absolute scroll offset* (found three times on anr9; the per-position forms survive for one-off lookups and say so). **The width rule has one home**, `improvise_protocol::header_level_widths` — widest label per level, plus a gap, clamped — reached by `AxisFrame::header_widths` and by `ui::viewport::compute_row_header_width`, which borrows level tuples off a `GridLayout` rather than building a frame on the keypress path. **A `cats` change re-identifies every position**: a cell's identity is `cats` zipped with its tuple, so `invalidate_relabelled` blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the **totals** on a recategorization of *either* axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in `apply`, not in a producer's memory). What is still **not** on the cache is `record_col`, and that is correct: it keys pending drill edits and is session-local (gotcha 1). `body_from(idx)` takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes `0`, the TUI's covers the whole grid and passes its row offset — not because the rule differs. `group_of` answers group **membership**, and since improvise-e265 membership is **the innermost open run**: `FrameGroup` and `FrameEntry::GroupEnd` each carry a `level` (an index into `cats`), runs **nest**, a header at level L replaces L and closes every deeper run, and a `GroupEnd { level }` closes L and deeper. Before e265 it was the nearest preceding header unless a level-less `GroupEnd` intervened (improvise-gd83, PR #77), which could not express one run inside another. `GroupEnd` draws nothing and exists only so membership is not inherited. `AxisFrame` deserialises through `UncheckedAxisFrame` and **refuses** a `level >= cats.len()` — a level naming no category is a malformed frame, not a clamp. And `FrameEntry::Item` is `{ tuple, collapsed }`: `collapsed` is the **layout's own fold flag**, read by `AxisFrame::folds`, so a client asks the frame whether a position folds rather than re-deriving it from the groups (improvise-4k1j). Both of the gaps this sentence used to list are **closed**, and it named them as open for a day after: `group_runs` coalesces by **header identity** (`std::ptr::eq`), not by name, so two runs under two headers equal as values are two runs (improvise-h0iv); and `AxisFrame::first` carries **no** `serde(default)` — its doc comment says why, since zero is a claim and not an absence (improvise-1wl0). `doctrine(a_stale_list_of_what_is_missing_is_worse_than_no_list)` is about exactly this paragraph, twice now. And `project::grid_of` used to evaluate every cell per frame where the widget evaluated only the visible ones; since improvise-felo.13 the host's own layout keeps a `GridSummary` (whole-grid widths, column formats and totals, `ui/viewport.rs::grid_summary`, held in `App::grid_geometry()`), and `grid_in` evaluates only a `grid_window` over it. The browser's window therefore costs its own cells. A session on another view, or a rederived model, still takes the whole-grid `grid_body` path. `grid_window` equals `grid_body` where the summary is current. Since improvise-jytm `grid_in` may additionally **derive a `GridLayout`** for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot. **`RecordHash` is document-scoped; `AggregateHash` is layout-scoped and cannot be otherwise** (improvise-gd28). A `RecordHash` resolves through `DataStore`'s maintained index — but **only when the command names a view the document has** (improvise-wpxr; Codex P2 on #281). `SetCell`/`ClearCell` carry `view`, and the lookup is **strict**: a view the document lacks drew nothing, stored or not, so it is `Rejection::RecordGone` rather than a `resolve_view` fallback. An **empty** record — one the store holds no cell for, which the grid still drew — resolves against **that named view's layout**, the only place it exists. `RenameRecordCoordinate` is the exception and resolves in the **store alone**, since only a record holding cells can be renamed: `protocol::named_records` answers each command's records as `NamedRecord::Drawn { view, record }` or `NamedRecord::Stored(record)`, one exhaustive match. Every one of these questions goes through `App::grids()`, a `Grids` borrow that memoises **one derived `GridLayout` per view for as long as the borrow lives** (improvise-kimw) — hold one across a check, never across a mutation, since the layouts it holds describe the model it borrowed. An aggregate has no existence outside the layout that derives it, so there is no index to fall back to and none can be built — `App::resolve_aggregate` therefore answers only for the **host's** active view, which since improvise-jytm is not the client's. **That, and not ADR 001 layer 2, is what blocks browser drill.** The ticket's blocking argument was right about the *commit* (`ApplyAndClearDrill` is addressed to one session, gotcha 38) and wrong about the *entry*: entering a drill needs no commit, and `CreateView`/`SetAxis`/`SetPageSelection` are already unaddressed `ModelCommand`s that name their view. The gap is one level down from where it was looked for — **the mutation and its operand have different scopes**: the mutation configures the shared `_Drill` and is addressed to nobody, while the operand exists only inside one session's layout. So the fix is not a new mechanism but the `view: String` convention every other view-scoped variant already follows, resolved against a layout derived for that name (improvise-obtu). Two things travel with it: editing *inside* a drill grid from a browser does not follow from it, because `edit_or_drill` declines records-mode editing out loud before reaching a cell (improvise-fdk1, since closed, is the separate records-coordinate rename on the wire — `RenameRecordCoordinate`, gotcha 38 — and did not unblock drill editing); and closing it makes improvise-t219 live, `_Drill` being one shared view in `Workbook::views` that two clients drilling would contend for — the same masking relationship improvise-6uva has with improvise-up8g. Guarded by `an_aggregate_a_session_was_shown_does_not_resolve_against_the_hosts_view`, whose **control** is the load-bearing half — the same surrogate resolves once the host is moved onto that view, so a blanket `None` cannot pass. 45. **One session loop, two transports — and the browser saves itself.** `Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec` and lives in `improvise_command::ui::host`, **not** in `improvise-ws-server`, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative `App` over a `MessagePort` and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting `Applied`/`Broadcast` match is invisible until a client sees it. `registry` stayed on the server, because a worker hosts **one** document. What the worker adds is only what a `MessagePort` differs in: **one client by construction** (the `Addressed` filter is honoured anyway, against a future `SharedWorker`), and **a handshake with no close** — `ClientFrame::Viewport` must still come first, but a pre-handshake frame is *dropped* rather than closing a connection that does not exist. - **Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag for any `EffectKind::Model(_)`, all three `ModelChange` levels included, which makes omitting it unrepresentable rather than validated. It over-approximates in the same direction `layout_version` does — a `LoadModel` that cannot read its path leaves the flag raised, costing one redundant write — and `Save` clears it in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command. + **Saving is a frame, and dirtiness is read off the classification the effect already carries.** `effect::Save` is `ViewOnly` (gotcha 3) — correctly, since it writes *this host's* `file_path` through *this host's* `Storage` — so the derivation that builds `ModelCommand` from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. `ClientFrame::Save` closes it (improvise-368h): a **request** carrying no path, because a client cannot see the host's storage, answered `ServerFrame::Rejected(NoDocumentPath | SaveFailed)` to the asker alone and **silently on success** — nothing moved, so no widget is dirty. It is a frame and not a `Command` for one concrete reason: as a `Command` it put a runtime-fallible operation inside a batch `ClientFrame::Commands` promises is all-or-nothing, so `[Save, SetCell]` reported a failed write and then applied the edit. It also fails the test every `Command` passes — it mutates no shared state and no client's slice, so `reduce_view` has nothing to fold and `Checked` has no basis to answer for. **And `MarkDirty` is gone.** It was a separate effect the *command* layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived `ModelCommand` from the effects **one at a time** and so put `SetCell` on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. `App::apply_effects` now raises the flag from the effect's `EffectKind`, which makes omitting it unrepresentable rather than validated — **declared** for `WholeWorkbook` (a replaced document differs from the file its path names, so a `LoadModel` that cannot read its path still raises it, costing one redundant write) and **observed** for `CellData` and `LayoutGeometry`, where `document_content`/`document_config` fingerprint the document before and after, so a write that leaves it as it was raises nothing (improvise-748f). The observation is read inside the loop, and `Save` clears the flag in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the **flag**, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason `cursor_epoch` beats matching on `CycleAxis` (gotcha 24) and `document_generation` beats matching on `LoadModel` (gotcha 39). It is also strictly tighter — a refused batch raises nothing, and neither does a write that changes nothing, so the "was anything rejected" guard went with it. A browser still never *sends* `ClientFrame::Save`, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document *is* the OPFS entry, so the worker persists on its own behalf through the one existing `Save` effect. The cost that remains: `Storage` is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command. **The async seam does not survive being boxed.** `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` are on the struct, not on `Storage`. `App::storage` is a `Box` and a trait object can name none of the four, so **handing the backend to the `App` puts the async seam permanently out of reach**; the host must hold the concrete backend *and* give the `App` a `Storage`, which means sharing one. That is `impl Storage for Rc`, and it lives in **`improvise-io`** rather than in each worker (improvise-hmws): `impl Storage for Rc` is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype that every future worker would have written again. `Rc` and not `Arc` because the one caller is a Web Worker and wasm32 has no other thread. **The flat root is no longer load-bearing**: `OpfsFs::list` joins `dir` like the other two backends, so `storage_contract` takes `dir` as a parameter and the three agree at *every* directory rather than only at `Path::new("")` where joining is the identity (improvise-vhcz). What the index holds is **documents, not entries**, and the split between its three writers is the rule: `attach` indexes nothing (what it creates is zero bytes), `Storage::write` indexes the name and un-queues any pending delete of it, and `refresh` rebuilds from OPFS skipping zero-byte entries — counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle (improvise-vvl3). @@ -279,6 +280,13 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text **One key table on screen, and it is the one derived from the keymap** (improvise-rr8n). `TileBar` now advertises no keys at all: its literal read `Ctrl+↑↓←→ to move tiles` where those keys are bound to `enter-tile-select`, and the derived status line is a strict superset (`Space`, `h`/`l`, `Tab` appear only there). The ticket asked for the opposite — rewrite the literal, suppress the derived line — and was inverted deliberately, because suppressing a derived superset to preserve a literal that had already rotted keeps the stale table and deletes information. The **import wizard goes the other way**, and the asymmetry is the rule rather than an exception: its status line *is* derived, but from the **mode** keymap while the real keys are per-step inside `HandleWizardKey`, so there the derived table is the one that lies and `draw_status` suppresses the fallback for that one mode. **Keep whichever table is derived from the thing it describes.** Two blind spots make this class invisible: `every_hint_key_resolves_in_its_mode` **cannot see a string literal in a widget** (the same gap improvise-wk18 names for `help.rs`), and the widget's own characterization test renders at **80 columns**, where the hint does not fit and is never drawn — so the one test pinning that output was structurally incapable of seeing either hint. + **A scroll is measured where it is drawn** (improvise-gtn7.1). The keypress path counted `visible_cols` from `term_width` while `draw_content` gave the grid `term_width` minus the 32-column side panel, so with any panel open the cursor scrolled to a column never drawn. Now `viewport::grid_width(term_width, side_panel_open)` is the one answer, reached through `App::grid_width`, and both `draw_content` and `App::cmd_context` read it; `App::side_panel_open` is the one answer to whether a panel took its column. The tile bar had the same shape: `first_tile` chose its scroll against a width that began one or two columns before where the tiles were drawn, and both now start at `tiles_left(start)`. The grid bug was latent from `b5418f2e`, when `compute_visible_cols` took `term_width`, and was exposed by `50e4a372`, which opened the category panel by default. Still open: columns of mixed width, where a `visible_cols` count taken at the old offset is applied at the new one (improvise-gtn7.2). + + +51. **The command language reads pipes, refuses surplus, and checks names when it applies (improvise-31oe.2, 31oe.3).** Reasoning: `context/code/cli.md` §14 and §15. (a) A `:` argument is read through `improvise_formula::quote`, so `create-view |By area|` names `By area`; a **surplus argument is refused** rather than ignored, and `cmd::core::only_when_omitted` gates the *cursor fallbacks* of `clear-cell`, `yank`, `paste` and `drill-into-cell` so a coordinate typed by the user is never overridden by the cursor. (b) `set-axis`, `set-page`, `toggle-group`, `hide-item`, `show-item` and `switch-view` return `RequireKnown(Needs)` (`effect.rs`) ahead of the real effect, built by `effect_cmds.rs::unless_unknown`. It is `ViewOnly`, writes nothing, is **off the wire** (listed in `vocabulary_is_complete.rs`) and checks the **document at apply time** — a `:` line builds every segment before any runs, so a check in `execute` refused `create-view Second . switch-view Second` — then refuses and aborts the batch (gotcha 21). (c) A refused name therefore stops the rest of a `:` line, while headless `improvise cmd` rebuilds the context per segment and runs each as its own batch (improvise-31oe.14, open). + +52. **A date column imports as one hierarchical category, and the import refuses what it cannot keep (improvise-8jk6.3, 8jk6.6, d0n6).** Reasoning: `context/code/formula-and-io.md` ("Import: …") and `cli.md` §13. The default `DateShape` nests Year > Quarter > Month (the chosen levels) in ONE category named after the column and opens folded to its years; `DateShape::Split` is the old flat split, chosen by `--split-date ` headless or `s` on the wizard's date step. `Plan::spelling_clash` refuses a raw, unparsed date spelled like a generated item or group (`2024`, `2024-01`) because the two would silently merge — asked at the wizard's ConfigureDates step **and** again at build time, so a headless import cannot reach it either. Headless, `--strict` turns a partial skip into a refusal before the build, and a folder whose CSVs are all header-only (no records) is refused as an import that kept no record. + --- ## File Inventory @@ -294,9 +302,13 @@ model/record.rs 226 / 8t RecordHash / CellId surrogates: the opaque i thin client names a cell by (ADR 002) model/category.rs 310 / 9t Category, Item, Group, CategoryKind model/symbol.rs 89 / 3t Symbol interning -view/layout.rs 2604 / 57t GridLayout, drill, records mode, records_columns +view/layout.rs 2604 / 57t GridLayout, drill, records mode, records_columns; a private + per-axis position index (`row_pos`/`col_pos`, rebuilt by `reindex`) + so a data position resolves in O(1) (improvise-felo.13) view/types.rs 817 / 43t View config (axes, pages, hidden, collapsed, format) -view/axis.rs 190 / 5t Axis enum + label/color/next methods, AxisColor +model/id.rs 290 slugify + IdAllocator: how a generated id is made (ADR 007 + clause 4, core.md §20). Nothing outside the file calls it yet +view/axis.rs 190 / 5t Axis enum + label/next methods (no colour: that is a Face) workbook.rs 413 / 22t Workbook: Model + cross-view ops, item facade format.rs 519 / 40t format_f64, parse_number_format (display only) ``` @@ -330,7 +342,10 @@ persistence/export.rs 243 / 5t export_csv / render_csv. Builds its layout w (improvise-iot6) persistence/roundtrip_tests.rs 1236 / 62t format_md ↔ parse_md characterization (tests only) -import/wizard.rs 1967 / 62t ImportPipeline + ImportWizard +import/wizard.rs 1967 / 62t ImportPipeline + ImportWizard. `Plan::place` is the one + placement pass, shared by `collisions()` and the build so + the two cannot disagree; `DuplicatePolicy` / `Collision` + (improvise-cgqt) import/analyzer.rs 434 / 17t Field kind detection (Category/Measure/Time/Skip); analyze_records is first-seen order, not HashSet (gotcha 18) import/csv_parser.rs 590 / 26t CSV parsing, multi-file merge @@ -353,6 +368,9 @@ keymap.rs 975 / 0t KeyPattern, Binding, Keymap, ModeKey (17), T dispatch in improvise-command/src/command/keymap.rs viewmodel.rs 170 / 5t flatten_cat_tree — derivations between a cache and a renderer, called by both front ends (improvise-edp) +face.rs 682 Face (a role: Axis, WizardField, Mode*, MinibufferOf…), Theme, + AnsiSlot — appearance as data a front end applies (ADR 004, + protocol.md §10); draw.rs::face_style is the TUI's one translation key.rs 96 / 3t KeyCode, KeyModifiers, KeyEvent (moved from command/) frame.rs 156 / 3t ClientFrame/ServerFrame — the ONE envelope pair, both deployments (improvise-xks6). Nothing else defines one. @@ -407,14 +425,14 @@ improvise-gp/src/fit.rs 1778 / 35t one fit end to end: a View's records in, cmd/core.rs 475 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers cmd/registry.rs 903 / 13t default_registry() — all registrations cmd/navigation.rs 507 / 12t Move, EnterAdvance, Page* -cmd/cell.rs 724 / 20t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd +cmd/cell.rs 724 / 20t ClearCellCommand, YankCell, PasteCell, TransposeAxes, SaveCmd cmd/commit.rs 1304 / 27t CommitAndAdvance, CommitBuffer(CommitKind), CommitExport, CommitSave -cmd/effect_cmds.rs 519 / 6t effect_cmd! macro, 25+ simple wrappers +cmd/effect_cmds.rs 519 / 6t effect_cmd! macro, 25+ simple wrappers, `unless_unknown` (gotcha 51) cmd/fit.rs 293 / 8t fit-measure: evolve a tree over the active view and store it as an ordinary `## Formulas` line — nothing GP-specific is left in the document afterwards -cmd/grid.rs 988 / 22t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty +cmd/grid.rs 988 / 22t ToggleGroupAtCursor, ViewNavigate, DrillIntoCell, TogglePruneEmpty cmd/mode.rs 469 / 13t EnterMode, Quit, EditOrDrill, EnterTileSelect cmd/panel.rs 936 / 24t Panel toggle/cycle/cursor, formula/category/view panels cmd/search.rs 202 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode @@ -429,11 +447,11 @@ parse.rs 350 / 23t Script/command-line parser (prefix syntax) ### Semantic UI state (`crates/improvise-command/src/ui/`) ``` -ui/effect.rs 4020 / 99t Effect trait. 62 effects: 52 `impl Effect for` blocks, 9 +ui/effect.rs 4020 / 99t Effect trait. 63 effects: 53 `impl Effect for` blocks, 9 simple_effect! expansions, and SetTransientPrefix in command/keymap.rs. The 10 outside the brace-match are all - ViewOnly; 28 of the 52 are Model(_) (gotcha 38, h3y7) -ui/app.rs 5558 / 134t App, ModelState, ViewState + dispatch methods, Panel, handle_key, dispatch_key, document_generation, autosave; apply_effects raises `dirty` off EffectKind::Model(_) (gotcha 45) + ViewOnly; 28 of the 53 are Model(_) (gotcha 38, h3y7) +ui/app.rs 5558 / 134t App, ModelState, ViewState + dispatch methods, Panel, handle_key, dispatch_key, document_generation, autosave; apply_effects declares `dirty` for WholeWorkbook and observes it for CellData/LayoutGeometry (gotcha 45) ui/project.rs 6383 / 118t Dirty, Viewport, grid_of/category_rows/formula_texts/ view_summaries/tile_entries, grid_updates (the diff), ClientSession + Sessions (connect/apply/rebase/project_all) @@ -444,7 +462,9 @@ ui/host.rs 3410 / 67t Host: one App + one Sessions; handle, comman rebase. Lives here, not in ws-server (gotcha 45) ui/cat_tree.rs 174 / 8t Category tree flattener for panel ui/viewport.rs 259 compute_col_widths / compute_row_header_width / visible_cols - (pure geometry; the width rule itself is the protocol's) + (pure geometry; the width rule itself is the protocol's); + grid_width, SIDE_PANEL_WIDTH, MIN_GRID_WIDTH (improvise-gtn7.1); + GridSummary / grid_summary / grid_window (improvise-felo.13) ``` ### TUI rendering, draw, main (`src/ui/`, `src/draw.rs`, `src/main.rs`) @@ -453,8 +473,11 @@ ui/grid.rs 905 / 14t GridWidget (ratatui): no &Model, no &View off GridCache (gotcha 44). Its module doc is the best-written statement of that rule in the repo ui/help.rs 277 5-page help overlay (render only) -ui/import_wizard_ui.rs 829 / 9t Import wizard rendering -draw.rs 1122 / 20t TUI event loop, frame composition +ui/import_wizard_ui.rs 829 / 9t Import wizard rendering. `Panel` is the one writer, and + every write is clipped to the panel's rows and width +ui/text.rs 169 truncate and wrap, both measured in terminal cells, never bytes +draw.rs 1122 / 20t TUI event loop, frame composition; `content_tests` draws a + whole frame to check the selection beside a side panel main.rs 428 CLI entry (clap): open, import, cmd, script # other ui/*.rs are small panel renderers — skip unless changing layout/style tests/grid_frame_agreement.rs @@ -555,6 +578,9 @@ harness/build.sh 50 assembles harness/serve/ (web/assemble.sh, ``` examples/gen-grammar.rs Grammar-walking random .improv generator (pest_meta) examples/pretty-print.rs Parse stdin, print formatted .improv +crates/improvise-core/examples/category_width_bench.rs + The same records at 12 and at 24 regular categories + (improvise-felo.1; context/code/core.md) ``` ### Context docs @@ -636,7 +662,7 @@ crossterm; that is correct, not a gap. `cargo check` passing is not the same as running. Two known runtime hazards it cannot see: `Instant::now()` panics on wasm (hence `web-time` in `improvise-command`), and any `std::fs` call compiles but fails in a browser -— see improvise-fuj for the two effects that still bypass `Storage`. +— improvise-fuj closed the effects that bypassed `Storage`; autosave and the headless final write still do (improvise-31oe.11). ## CLI @@ -647,7 +673,7 @@ improvise cmd 'add-cat Region' -f model.improv # headless command(s) improvise script setup.txt -f model.improv # run script file ``` -Import flags: `--category`, `--measure`, `--time`, `--skip`, `--extract`, `--axis`, `--formula`, `--name`. +Import flags: `--category`, `--measure`, `--time`, `--skip`, `--extract`, `--axis`, `--formula`, `--name`, `--split-date ` (a date column imports as ONE hierarchical category by default; this restores the flat split, and `--extract` still picks the components — improvise-8jk6.3), `--strict` (with `--no-wizard`, any skipped record is a refusal and nothing is written, where the default writes and warns with a count — improvise-8jk6.6), and `--on-duplicate refuse|sum|separate` (default `refuse`; applies with `--no-wizard`, where the wizard would otherwise ask — improvise-cgqt). A headless import also refuses a `--split-date` that names a non-date field and an `--axis` that names a category the import did not build (exit 1). --- diff --git a/context/test-evidence/cli.md b/context/test-evidence/cli.md index be0c036f..ef103f13 100644 --- a/context/test-evidence/cli.md +++ b/context/test-evidence/cli.md @@ -407,3 +407,254 @@ named, and no output file. It has two siblings. `..._sums_duplicates_when_told_t asserts `= 30` and no `Occurrence`. `..._keeps_duplicates_separate_when_told_to` asserts the category, `= 10` and `= 20`, and no `= 30`. So the two flags cannot both be summing. + +### a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte + +improvise-lpzu, at the process boundary. The folder holds code-maat's three +schemas (coupling, age and abs-churn) and a `README.txt`. At `e65d87fd`'s +parent the run failed with `Cannot read '…/maat': Is a directory (os error +21)`, because a single path went straight to `read_to_string`. The test +asserts: + +- sparse Data lines for all three schemas, with no README; +- the four views, `Default` first and initial, then one per file; +- `File: filter, coupling`; +- a second run, with the first run's `maat.improv` now inside the folder, + writing the same bytes. + +Rivals R2, R5, R6 and R7 each fail it. + +### a_headless_import_takes_files_and_folders_together_and_refuses_an_empty_folder + +improvise-lpzu. A file argument and a folder argument together are one merge, +and the extra file's records carry its own `File` item. Before the fix this +failed with `Multi-file import only supports CSV files`, because the folder was +not a `.csv` path. An empty folder exits non-zero, writes nothing, and names +itself: `no CSV files in folder …/empty`. Rivals R2, R4 and R5 each fail it. + +### a_saved_per_file_view_projects_only_its_file + +improvise-lpzu (`tests/folder_import_views.rs`). A folder import is saved, +reloaded and projected through `project::grid_in`, the one derivation the TUI +grid and the browser both draw from (gotcha 44). This is why no separate TUI +or browser test was written. For each file's view: + +- the page bar carries `File` fixed at that file with nothing to cycle + (`items: None`, gotcha 27); +- the drawn cells are that file's alone: `2.00, 7.00` for age and + `5.00, 3.00` for coupling. + +Rivals R2, R7 and R9 each fail it. Since the review of `4e68372c` the fixture +also holds a header-only `soc.csv`. Its view must carry `File` at `soc`, be a +records view, and draw zero rows. Rivals F7 and F8 fail that half. + +### a_csv_column_forced_to_category_keeps_its_text_verbatim + +improvise-8jk6.4. Before the fix, `commit,lines` with `00565796` and +`220944e2` forced to Category imported as items `565796` and `22094400.0`. +The CSV parser had typed both as numbers, and the category was named from the +number. On the base this failed with the items line `- |565796|, |22094400.0|`. +The test also asserts both Data lines, which proves the measure `lines` still +reads 3 and 5 through the same records. + +- **R1**, a category named from the typed value, fails it. +- **R2**, a CSV measure read as a JSON value (`as_f64` on the text), fails it + on the Data lines. + +### a_json_number_forced_to_category_is_named_as_json_writes_it + +improvise-8jk6.4, the JSON half. This is not a bug demonstration: it passed +on the base, and it pins that the fix left JSON alone. JSON keeps no source +text for a number, so `42`, `1.5` and `1e3` name items `42`, `1.5` and +`1000.0`. `1e3` is the discriminating value, because its source text, its f64 +display (`1000`) and its JSON display (`1000.0`) all differ. **R10**, naming a +number by its `f64` display, fails it on `1000.0`. + +### an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing + +improvise-8jk6.5. Before the fix, `--formula 'Y = x +'` was dropped: the run +printed `Saved to …` and exited 0. That is the base failure (`a dropped +formula exited 0`). The test passes a good formula too (`Z = x`) and asserts +it is not named, so a refusal that lists every formula cannot pass. +`expected` comes from pest's error, which proves the parse error is carried +and not only the formula text. **R4**, a build that drops what does not +parse, fails it. Since improvise-8jk6.17 it passes two bad formulas, so +**R5**, naming only the first, fails it here too. At `efaa5050` only the io +unit test saw R5. + +### an_import_that_keeps_no_record_refuses_and_says_why + +improvise-8jk6.6. On the lpzu base `[1, 2, "three"]` already exited 1, so the +exit-code half was not the bug. The reason was: `At least one category must +be accepted`, which is true only because no fields exist, and which points +the user at a fix that cannot help. The base failure was on the message. +**R7**, removing `nothing_to_import` from the build, fails it the same way. + +### an_import_that_skips_some_records_warns_with_a_count_and_still_writes + +improvise-8jk6.6, the partial case. It passed on the base. It pins the +default the user kept on PR #321 ("Add a --strict flag"), so that changing +the default is a decision and not a drift. It asserts the warning's count (`1 of 3`), exit 0, +and a Data line from the record *after* the skipped one. That last check +shows the skip did not end the import early. **R9**, making any skip fatal, +fails it. + +### a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not + +improvise-lpzu, after the user's ruling on PR #314 ("we'd want file identity"). +At `4e68372c`, a folder holding one CSV took the single-file path, because +`import dir/` was exactly `dir/*.csv`. That document had no `File` category and +no per-file view, and the test failed at the `## Category: File` assertion. The +other half pins the direction the ruling did *not* change: the same CSV named +directly imports without any `File`. The rival that merges every all-CSV +import, lone files included, fails that half ("a lone file keeps today's +shape"). So the test separates "a folder merges" from "everything merges". + +### a_pipe_quoted_argument_is_one_argument_without_its_pipes + +improvise-31oe.3. Red before the fix: the old tokenizer split on whitespace +only, so `create-view |By area|` built `CreateViewCmd(["|By", "area|"])`. It +asserts the whole built command, so a lexer that joined the words but kept the +pipes fails as well as one that split them. The second case has two quoted +arguments side by side, so a lexer that reads one pipe piece per line fails. +Rival: `bare_word` pushing `|` as a plain character. Fails here and in +`a_coordinate_may_pipe_quote_either_half`. + +### a_pipe_quoted_argument_reads_the_escapes_improv_does + +The three escapes `pipe_quote` writes, `\|`, `\\` and `\n`, each in its own +case. Red before the fix. Rival: `pipe_body` returning the raw body without +`unescape_pipe_body`. Fails here, at the first case, and nowhere else in the +crate, which is why the case exists. A lexer that treated `\|` as a closing +pipe would also fail the first case, because the pipe after `b` then opens a +piece that never closes. + +### a_command_given_more_arguments_than_it_takes_is_refused + +improvise-31oe.3's second half. Red before the fix: `create-view By area` +parsed and dropped `area`. It asserts the refusal opens with the command's name +and quotes the surplus word, and that the pipe-quoted spelling of the same line +is the control that parses. Rival: `MaxArgs::admit` answering `Ok` always. +Fails here, in `no_command_silently_drops_an_argument_it_was_given`, and in +`a_colon_line_naming_something_missing_refuses_on_the_status_line`. + +### no_command_silently_drops_an_argument_it_was_given + +The rule is one rule only if every registration obeys it, so this enumerates +every registered name against every candidate argument list, on both arms. For +each list a command accepts, it appends **four** surplus words. One would be +absorbed by an optional argument (`add-formula ["1"]` plus one more is a valid +two-argument call), and four is more than any optional span in the registry. +The last word must be named in the refusal. The exceptions are stated, not +inferred: the `Any` list, and three commands whose own shape refusal runs +first. Rivals, each run: declaring `add-item-in-group` as `Any` fails here, +naming the dropped list. Removing the check from the interactive arm alone +fails here and in the `:` line test. A test of the pure arm alone would have +missed that. + +### a_command_naming_something_the_document_lacks_is_a_refusal + +improvise-31oe.2 and its siblings: `set-axis`, `set-page`, `hide-item`, +`show-item`, `toggle-group` and `switch-view`, each naming a missing category, +item, group or view. Red before the fix at the first row, `set-axis Regoin +column`, with an empty status. Each row asserts the refusal flag, the missing +name in the message, and that the document's `.improv` text and the active view +are unchanged. The last two catch a refusal written after the write. The +control is `a_command_naming_what_the_document_has_is_not_refused`, whose group +row is the one that caught a group check reading only `Category::groups`: +`add_item_in_group` registers no `Group`. The checks are `effect::unknown_name`, +run by `RequireKnown` when the batch applies. Rivals, each run: the +`Needs::Item` arm answering `None` fails at `set-page ["Type", "Nowhere"]`. The +category test (`try_axis_of(..).is_none()`) never firing fails at the first +row. The `Needs::View` arm answering `None` fails at the `switch-view` row. +`RequireKnown` placed after the write, or refusing without `refuse_and_stop`'s +abort, fails the document-unchanged assertion. + +### a_colon_line_naming_something_missing_refuses_on_the_status_line + +The TUI path for both tickets, through `ExecuteCommand` and `App::apply_effects`, +not through the command alone. It asserts the refusal flag, the name on the +status line, and the mode back at Normal. Rivals: an unknown category accepted +fails at `:set-axis Regoin row`, and the arity check removed from the +interactive arm fails at `:create-view By area`. + +### an_unknown_category_axis_or_item_exits_non_zero_and_names_it + +The three repros from improvise-31oe.2, at the process boundary. Red against +the `gh/main` source with this test file: `set-axis Regoin row` exited 0 with an +empty stderr. The control, `set-page Region East`, must exit 0. Without it, a +fix that refused every `set-page` would pass. + +### a_pipe_quoted_name_reaches_the_document_whole + +improvise-31oe.3's repro at the process boundary: the saved document must carry +`## View: By area` and no `|By`. The second half, `create-view By area`, must +exit non-zero naming `"area"`. Red against the `gh/main` source: the document +carried the view `|By`. + +### command::cmd::registry::tests::a_malformed_coordinate_is_refused_not_dropped + +Codex's P2 on PR #316: `set-cell` and `parse_cell_key_from_args` (`clear-cell`, +`yank`, `paste`, `drill-into-cell`, `commit-cell-edit`) built coordinates with +`filter_map(split_once('/'))`, so `set-cell 1 Type/Food unexpected` dropped +`unexpected` without a word. The test names every caller, a half-empty +coordinate, and a control line that must still parse. Rival: the `filter_map` +restored in `parse_cell_key_from_args` fails at the first line, +`set-cell 1 Type/Food unexpected dropped "unexpected" without a word`. + +### command::cmd::text_buffer::tests::colon_refuses_a_malformed_coordinate_and_leaves_the_cursor_cell_alone + +The PR #316 review's blocker: on `:`, `parse_cell_key_from_args` failing sent +`clear-cell`, `yank`, `paste` and `drill-into-cell` into the cursor fallback, +which ignores its arguments. `:clear-cell Type/Clothing Month/Feb junk` cleared +the cursor's Food/Jan without a word. The test runs each line through the full +`:` path and asserts a refusal naming `"junk"`, both cells intact, nothing +yanked and no drill opened. Rival: `only_when_omitted` made unconditionally +`Ok` fails it at the first line. + +### command::cmd::text_buffer::tests::colon_lets_a_later_segment_use_what_an_earlier_one_created + +The review's second blocker: a `:` line is built before any segment runs, so the +name checks in `execute` refused `create-view Second . switch-view Second` and +`add-item Type Tools . set-page Type Tools` after the first half landed. The +checks now run when the effect applies. Rival: the checks back in `execute` +fails both lines with `No view named "Second"` and `Unknown item 'Tools'`. + +### a_strict_import_that_skips_a_record_refuses_and_writes_nothing + +improvise-8jk6.6, the user's ruling on PR #321. Written before `--strict` +existed, it failed on clap's unknown-argument error. It asserts the refusal +names the flag and the same count as the default warning, and that the output +path does not exist. + +- **S1**, the flag parsed but ignored, fails it on the exit status. +- **S3**, refusing only after `persistence::save`, fails it on the output + path. That is the "a strict refusal still writes" contract this PR decided + against. + +### a_strict_import_that_skips_nothing_still_writes + +The control for the entry above. Without it, a `--strict` that refuses +**every** import passes that test. **S2** is that rival, and it fails here. +It failed before the flag existed too, but only on clap's error, which is not +the discriminating failure. The discriminating one is S2. + +### a_csv_decimal_measure_imports_as_its_number + +improvise-8jk6.17 (F3), from the adversarial review of #321. The fixture has +decimal CSV measures (`1000.50`, `-2.25`). With `number_in`'s `f64` branch +disabled (integers only), no headless test failed at `efaa5050`, and a decimal +measure was silently dropped. **F3** now fails this test and the next one. +This is coverage, not a bug: it passed when written. + +### import_json_on_a_csv_reads_its_numeric_column_as_a_measure + +improvise-8jk6.17 (F1). `improvise cmd 'import-json f.csv'` reaches +`ImportJsonHeadless`, a third CSV entry point besides `import`. **F1** +restores its CSV branch to the JSON constructor via +`ImportPipeline::new(Value::Array(recs.to_vec()))`. That still compiles, +because `CsvRecords` derefs to a slice. `Revenue` then becomes a category of +text items, and this test fails on its Data lines. At `efaa5050` only +`import_headless_refuses_records_that_collide` failed under F1, and only by +accident. The `## Category: Revenue` assertion names the symptom directly. +This is coverage: it passed when written. diff --git a/context/test-evidence/clients.md b/context/test-evidence/clients.md index f6b506a5..e1df39d3 100644 --- a/context/test-evidence/clients.md +++ b/context/test-evidence/clients.md @@ -6605,3 +6605,19 @@ commits. `target.view`), run across the whole workspace: fails **only** this test. - A composer that hard-codes `"Default"` fails here as well. Neither view in this test is `Default`, so the default cannot satisfy it by accident. + +### a_download_commit_keeps_the_host_session_mirror_in_lockstep + +Codex on PR #329: `LocalThenRequest` closed and cleared the browser's download +prompt, but the bytes-only request moved no corresponding state in the host's +session mirror. The test opens SavePrompt through the real browser key path, +types a nonempty filename, and commits it. It asserts both that the response is +still `DocumentBytes` and that the host mirror is Normal with an empty save +buffer. Another session then writes a cell; the requester folds that later +projection and must still equal its host mirror. + +- **Rival** (the pre-fix download handlers, which returned bytes without + closing the mirrored prompt): fails at the host-mode assertion with + `SavePrompt` versus `Normal`, before the later projection. +- The nonempty filename proves Enter commits rather than taking the empty-name + refusal, and matching `DocumentBytes` preserves the bytes-download ruling. diff --git a/context/test-evidence/command.md b/context/test-evidence/command.md index 40086686..08ce94cb 100644 --- a/context/test-evidence/command.md +++ b/context/test-evidence/command.md @@ -2479,8 +2479,8 @@ from `DocumentSnapshot` — it is no longer only the document. **The fixture detail that matters**: a *duplicate* `AddCategory` is idempotent, so the obvious way to reach the refusal proves nothing. This fills to -`MAX_CATEGORIES` (12) and overflows. And the predecessor is an `AddItem` rather -than a `SetCell`, because adding eleven categories reshapes the view enough that +`MAX_CATEGORIES` and overflows. And the predecessor is an `AddItem` rather +than a `SetCell`, because filling to the limit reshapes the view enough that a record-addressed predecessor is refused `RecordGone` **first** — hiding the arm under test behind an earlier one. @@ -3505,7 +3505,7 @@ handshake. The stronger form fails under both injections. improvise-75ld. `App::apply_effects` bumped `layout_version` and raised `model_state.dirty` off the `EffectKind` **before** the `abort_effects` check, so the refusing effect counted itself. Two arms, and the second is the reachable one: -a 13th category is refused (`MAX_CATEGORIES`) and aborts, while a *duplicate* +a category past `MAX_CATEGORIES` is refused and aborts, while a *duplicate* `add-cat Region` returns `Ok` and does not abort at all — a pure no-op that still refused every other client's next `Command::Model` with `Rejection::LayoutMoved` and made the browser worker rewrite the whole OPFS document (gotcha 41). @@ -7214,3 +7214,16 @@ cleared the cell cannot pass. `the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable` wrote with `view: "Default"` against `web/demo.improv`, whose view is `Plan`. It passed only because a stored record ignored the view name. It now names `Plan`. + +### the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy + +improvise-felo.6. The windowed frame carries the runs open above the window; +those are now keyed by `(level, depth)`. Rival: `axis_frame` closing carried +runs by level alone. Only this test fails; the existing agreement test has one +group per level, where the two keyings coincide. + +### z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again + +`z` through a closed Year > Quarter > Month hierarchy, cursor at row 1. Rival: +`toggle_group_collapse` never removing a fold; the final assertion (no fold left +behind once the quarter is closed again) fails. diff --git a/context/test-evidence/core.md b/context/test-evidence/core.md index fb4b2e6e..354bceba 100644 --- a/context/test-evidence/core.md +++ b/context/test-evidence/core.md @@ -710,11 +710,6 @@ Applying `next` four times is the identity from every starting point and no navigable axis is a fixed point, so no variant is stranded or skipped. Together with the entry above this pins the rotation without enumerating it twice. -### every_axis_gets_its_own_color - -Distinct axes must be distinguishable at a glance; `color()` is the only reason -`AxisColor` exists, so a collision defeats it. - ### a_routing_formula_over_a_group_sums_the_members_answers improvise-4zl7, and the hand-computed half of its evidence. Every number in @@ -1865,5 +1860,167 @@ were run against the whole `improvise-core` suite: **What it cannot see is the allocation itself.** A visitor that clones the stack at every row returns the same answers, so no assertion here can tell the two apart without a counting allocator. The copy-free property is held by the -shape: the visitor receives a borrowed `&[Option]`, and no owned stack +shape: the visitor receives a borrowed slice of open runs (`&[OpenRun]` since +improvise-felo.6), and no owned stack leaves the walk. It is not held by a test. + +### a_parent_group_covers_every_leaf_beneath_its_child_groups + +improvise-felo.6. `items_in_group` read only the items whose own `group` named +the group, so a year covered nothing: its months sit in quarters. Rival run +against `improvise-core`: the direct-only filter restored. Four tests fail — +this one, `a_hierarchy_opens_closed_as_one_fold_per_root` (the 2024 fold reads +empty), `collapsing_a_parent_folds_its_children_whatever_their_state` and +`hidden_items_leave_every_fold_above_them`. The fixture adds `2024-03` last, so +the year and its first quarter are non-contiguous in insertion order. + +### a_year_quarter_month_hierarchy_nests_its_runs + +improvise-felo.6: the whole marker sequence of a Year > Quarter > Month axis +opened to depth 2, compared exactly. Rival: every header inside a category +emitted at depth 0 (a flat hierarchy). This test fails, and so do +`pruning_keeps_every_surviving_rows_runs_at_every_level` (the `NESTED` fixture), +`the_nested_fixture_draws_runs_inside_one_category` and +`pruning_drops_an_empty_inner_group_and_keeps_its_parent`. + +### a_hierarchy_opens_closed_as_one_fold_per_root + +The user's ruling on improvise-felo.6: groups start closed. Two rivals fail it. +The old open default (`inherits_collapsed` always false) fails twelve tests +across `improvise-core` and `improvise-io`, this one among them. The INNERMOST +collapsed group folding instead of the outermost (`.rev()` on the fold search) +fails this one, `hidden_items_leave_every_fold_above_them` and +`the_group_under_the_cursor_is_the_innermost_at_every_depth`: under it a closed +year shows its quarters as rows. + +### a_groups_depth_opens_it_and_n_minus_one_levels_beneath + +`expanded(2): Time/2024` opens 2024 and its quarters, not its months. Rival: an +ancestor reaching `k <= n` levels instead of `k < n`. Only this test fails, which +is why it exists: the boundary is the whole semantics of N. + +### a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it + +`z` writes `Collapsed` or `Expanded(1)`, or removes the fold where the flip +lands on what the group inherits. Rival: never remove. This test and +`command::cmd::grid::tests::z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again` +fail; every layout test passes, since a redundant fold draws the same grid. What +the rival breaks is the file (a line per toggle, forever) and `z z` being the +identity on the view. + +### pruning_drops_an_empty_inner_group_and_keeps_its_parent + +`prune_empty` at depth: an empty quarter goes, header and all, and its year's +header stays for the sibling quarter that has data. Rival: `prune_axis_items` +keying open runs by level alone. This test and the `NESTED` enumeration fail. + +## `model/id.rs` (ADR 007 clause 4, improvise-cq1q.2) + +These tests were written before `slugify` and `IdAllocator` had bodies. With +`todo!()` bodies all eleven failed. Each rival below was then injected into the +finished module, run against `cargo test -p improvise-core --lib model::id`, and +reverted. The reasoning is `context/code/core.md` §20. + +### model::id::tests::slugify_case_folds_rather_than_lowercases + +Rival: `display.to_lowercase()` in place of `unicase`'s full folding. Only +this test fails: `left: "strae"`, `right: "strasse"`. `ß` lowercases to itself +and step 3 strips it. `slugify_follows_the_adrs_examples` cannot see this rival, +because each of its displays lowercases and folds alike. + +### model::id::tests::slugify_collapses_a_run_of_spaces_to_one_underscore + +Rival: every space becomes `_`, with runs not collapsed. Only this test fails: +`"time___period"`. No other fixture has two spaces in a row. + +### model::id::tests::slugify_keeps_an_underscore_the_display_already_had + +Rival: `.replace("__", "_")` after the space step, which reads answer 21 as +"no doubled underscores". Only this test fails: `left: "a_b"`. The rule +collapses runs of *spaces*. An underscore that was in the display is kept. + +### model::id::tests::the_suffix_is_the_next_counter_value_not_the_lowest_free_number + +Rival: the lowest free `-` for each base, which revision 5 of the ADR +proposed and answer 28 replaced. Three tests fail: this one (`"west-1"` against +`"west-2"`), `an_empty_slug_takes_its_kind_and_the_shared_counter` (`"item-1"`) +and `a_slug_taken_by_another_kind_takes_a_suffix` (`"time_period-1"`). Only +interleaving two bases separates one shared counter from per-base numbering, +and this test is the one that does it on purpose. + +### model::id::tests::a_claimed_id_keeps_its_spelling_and_generation_steps_past_it + +Rival: `claim` validates the id but does not record it. This test and +`claim_refuses_a_duplicate_a_malformed_and_a_reserved_id` fail. The second +`East` is issued `east-1`, the file's own id, which is the collision answer 29 +forbids: "doing otherwise will corrupt the data section". + +### model::id::tests::generated_ids_are_distinct_well_formed_and_never_reserved + +Rival: `generate` does not record the id it issues, so nothing is retired. Five +tests fail, this proptest among them (`reissued`), along with every +suffix-order test. The proptest is the one that states the property itself: no +id is issued twice in a session, whatever the displays. + +### model::id::tests::two_allocators_fed_alike_generate_alike + +This test builds two allocators independently, so they share no hash state, +rather than calling one twice. `seen` is a `HashSet`, but it is only ever +queried and never iterated, so its order cannot reach an id (gotcha 18). No +rival of the current shape breaks this test. It guards against a future +change that picks a candidate by walking `seen`. + +### model::id::tests::a_long_claimed_run_of_suffixes_is_walked_without_recursion + +This is the Codex P2 on PR #318 (review of `a2ddb06e`), reproduced by the adversarial review of +`689480bf`. `IdAllocator::suffixed` recursed once per taken candidate, and the +call was not in tail position. A new-version file saved from a non-ASCII import +claims `item-1` … `item-N`, and the counter restarts at 1 on load, so the +next empty-slug generation walks all N. Against the recursive version this test +(N = 20,000) aborted the whole test binary: `has overflowed its stack` / +`SIGABRT`. It is an abort, not a failed assertion, so no test could catch it. +The loop passes, and still advances the counter on every candidate it tries. +The second assertion, `category-20002`, pins that: a loop that restarted from +a local counter on each call would answer `category-1`. + +### model::id::tests::claim_refuses_an_id_that_only_begins_like_a_virtual_one + +Found by the adversarial review of `689480bf`. With `claim` rewritten to +exempt any id that begins with a virtual id (`VIRTUAL_IDS.iter().any(|v| +id.starts_with(v))`), all 11 earlier tests passed, because +`claim_refuses_a_duplicate_a_malformed_and_a_reserved_id` tries only `_draft`. +This test fails on that rival (`left: Ok(())` for `_indexer`). The match is +exact by the ADR's own wording: answer 23 says no other id may begin with `_`. + +### model::id::tests::a_display_with_leading_spaces_never_slugs_into_the_reserved_space + +Rival, from the same review: strip the leading `_` before the space step, so +that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does +`generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test, +the proptest was the only thing that saw the rival. + +## Displays beside keys (ADR 007 clause 2, improvise-cq1q.3, first slice) + +Each renderer test below builds a projection whose display differs from its key. +The test-only way to get one is `set_display`, since no production path makes +them differ yet. Every test failed before the change, with the key drawn where +the display should be: `left: "Region"` against `right: "Sales Area"` in the +projection and wasm tests, and a missing "Sales Area" or "Overview" in the TUI +buffers. + +### ui::project::tests::the_category_panel_shows_a_display_and_keeps_the_key_commands_name + +It finds the row by `name == "Region"` before asserting on the display. A rival +that writes the display into `name` fails the lookup, which covers the half that +keeps commands working. `_Measure` is checked to fall back to its own key. + +### ui::view_panel::tests::a_view_row_draws_its_display_and_marks_the_active_view_by_its_key + +Rival: the active marker compares `summary.display == active_view`. This test +fails on that rival (no `▶ Overview`), because the active view is a key +(`ViewState::active_view`) and its display differs from it. + +### render::tests::the_panels_paint_displays_and_keep_keys_for_what_a_command_names + +The browser half of the same three panels. It also asserts that the view row is +still `active` when found by key. diff --git a/context/test-evidence/formula-and-io.md b/context/test-evidence/formula-and-io.md index 044a4dca..5284af34 100644 --- a/context/test-evidence/formula-and-io.md +++ b/context/test-evidence/formula-and-io.md @@ -882,6 +882,11 @@ The control: a **lone** `\r` is itself a record terminator to this reader, so it is not part of a CRLF pair, and it is why "two records, clean values" is a real observation rather than a coincidence. +Since improvise-8jk6.4 the parser yields text, so the assertion is that +`Revenue` is the text `1`. A kept `\r` would make it `1\r`, which fails that +assertion, and which `number_in` would not read as a number where a measure +wanted one. + ### an_empty_file_is_no_records_and_no_warnings An empty file is no records and no complaint. The wizard's own preconditions @@ -919,12 +924,17 @@ The consequence downstream is deliberate and worth stating: a column carrying Label rather than a Measure. That is the honest answer — the column is not numeric — and it is visible in the wizard, where a fabricated zero was not. +Since improvise-8jk6.4 the numeric rule is `csv_parser::number_in`, applied +through `typed` only where a number is wanted, and the parser itself yields +text. So this test asserts that `typed` leaves each token as text. The +`unwrap_or` fault would be injected into `number_in` now. + ### an_ordinary_float_is_still_a_number The control for the entry above. Guarding non-finiteness by deleting the float branch entirely would also pass that test, so this one pins that `1000.50` is still a `Number` and that `0` — the value the bug invented — is still reachable -when the file really says so. +when the file really says so. Since improvise-8jk6.4 both go through `typed`. ### a_repeated_header_is_renamed_rather_than_overwriting_its_twin @@ -1002,7 +1012,7 @@ the error, rather than moving to a `Done` step with nothing to confirm. ### wizard_blocks_advance_when_over_category_limit -Bug improvise-mzv: with more than `MAX_CATEGORIES` (12) accepted Category +Bug improvise-mzv: with more than `MAX_CATEGORIES` (12 then, 24 since improvise-felo.1) accepted Category proposals, the wizard let the user advance through every remaining step and `build_model()` only failed at the final confirm. Advancing past `ReviewProposals` must be blocked, with a message telling the user to mark @@ -1026,6 +1036,14 @@ every blank column value in a CSV import grew a phantom "null" item. Null means *absent*: the record must be skipped and counted, exactly as if the key were missing. +**The skip is superseded by improvise-8jk6.2.** Null still means absent, and +still invents no item. But an absent coordinate no longer drops the record: it +keeps its cell without that coordinate. So the test now asserts both cells +(`region=East` and the bare `_Measure=revenue`) and no warning. The rival that +restores the skip (a present-but-blank category returns the record as +unplaceable) fails here, beside +`a_record_with_no_value_for_a_category_is_kept_without_that_coordinate`. + ### build_model_literal_string_null_is_a_real_category_item The other side of improvise-ych: the *string* `"null"` is an ordinary value — a @@ -1038,7 +1056,9 @@ null" would pass the test above. improvise-ych, third arm. An empty string is the JSON spelling of a blank cell — the CSV parser already turns a blank field into `Value::Null`, so the two inputs must agree. An item with an empty name is unnameable in the grid, so it is -treated as missing rather than created. +treated as missing rather than created. Since improvise-8jk6.2 "missing" +means the record keeps its cell without a `region` coordinate. It is no longer +skipped, and the same skip-restoring rival fails here. ### build_model_sums_records_that_share_every_coordinate @@ -1107,12 +1127,19 @@ and scrolls them. It was red at compile time against the old `Option` signature, then red against a `Vec` that still held one elided sentence. The rival **elided after three** fails it. -### build_model_warns_about_records_skipped_for_missing_category +### improvise-5j9's skip warning, retired by improvise-8jk6.2 improvise-5j9: skipping a record for a missing category value used to be silent, -so a 1000-row import could drop rows with no user-visible sign. The warning must -name the count, the total, and the offending field(s) — a bare "some records were -skipped" is not actionable. +so a 1000-row import could drop rows with no user-visible sign. The warning had +to name the count, the total, and the offending field(s); a bare "some records +were skipped" is not actionable. Its test, +`build_model_warns_about_records_skipped_for_missing_category`, was **deleted** +by improvise-8jk6.2. A record missing a category value is now kept without that +coordinate, so there is no skip left to report. Its successor is +`a_record_with_no_value_for_a_category_is_kept_without_that_coordinate`, which +asserts no warning at all. `build_model_record_with_missing_category_value_skipped` +became `build_model_record_with_missing_category_field_is_kept` and now asserts +the kept cell rather than only the East one. ### is_does_not_swallow_the_start_of_a_longer_word @@ -1355,7 +1382,7 @@ Faults injected, both against the fixed tree: ### a_document_at_the_category_limit_plus_a_label_reloads The P1 half, and the reason improvise-dy3j was not merely a lost flag. `Label` -is outside `MAX_CATEGORIES = 12` by design — `Model::add_label_category` has no +is outside `MAX_CATEGORIES` (12 then) by design — `Model::add_label_category` has no limit check, and the import wizard's `proposed_category_count` scores a label as zero — so a document with twelve categories and one label is legally constructible and the wizard will build one from any CSV carrying a free-text @@ -1364,7 +1391,7 @@ refused, and `parse_md` returned `Err("Maximum of 12 categories reached")`: **the document was permanently unloadable.** Reproduced at `d46ec59` with exactly that error. -The second assertion (`regular_category_names().len() == 12`) is what keeps the +The second assertion (`regular_category_names().len() == crate::model::MAX_CATEGORIES`) is what keeps the first honest. A "fix" that simply raised or removed the limit would let the document load while putting a high-cardinality label into the pivot count, and this assertion fails on it. @@ -1436,7 +1463,16 @@ The fix reports and does not relocate: the row still lands with the coordinates it does have, exactly as before. Changing where such a row is placed is a behaviour change the ticket did not ask for. -### import::wizard::tests::a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally +### import::wizard::tests::a_row_kept_without_a_category_is_charged_to_the_date_tally + +**Renamed from `a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally` +by improvise-8jk6.2.** Its premise went with the skip. A row missing `Region` +now lands, so its unparseable `Date` *is* a derived column's loss, and it is +charged once to each of `Date_Month` and `Date_Year`. The test asserts the +landed cell (`Date=2025-02-05, _Measure=Amount = 200`, with no `Region` and no +derived part) and exactly those two warnings. The history below is kept +because the rule it established (charge only landed rows) still holds. Every +object row now lands. improvise-yss5, found by ChatGPT Codex reviewing PR #150 at `af7fae0e` and correct. The first fix incremented `unparsed_dates` while walking a record's @@ -1566,8 +1602,11 @@ bounded to four advances, so the rival fails it rather than stalling the suite. ### keeping_duplicates_separate_is_refused_at_the_category_cap -Twelve categories plus a collision, which is the reported import. It asserts -option (b) is disabled with a reason naming 12 and Skip, that choosing it leaves +A full set of categories plus a collision, which is the shape of the reported +import. The fixture is `category_records(MAX_CATEGORIES)`, so it tracks the cap +(12 when the test was written, 24 since improvise-felo.1). It asserts option (b) +is disabled with a reason naming the cap (`reason.contains(&cap)`, where `cap` +is `MAX_CATEGORIES` spelled out) and Skip, that choosing it leaves the step and the policy (`Refuse`) untouched, and that the message is the reason. The rival **cap checked with `>`** fails it. @@ -1614,3 +1653,396 @@ that name, which must be refused. It was green on arrival, so its evidence is the reviewer's rival, **date-derived names dropped from the taken names**, which fails it. That fault would have let `add_category` reuse the existing category, and every cell key would then name `when_Year` twice. + +### a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group + +The user's ruling on improvise-felo.6. Three spellings of one group given two +folds, each refused as `Refusal::FoldConflict` naming the view and the group, +plus a category given two depths. Rival: a second fold for a group silently +wins. Only this test fails. Its control is +`a_collapsed_group_inside_an_expanded_region_loads`: a DIFFERENT group +collapsed inside an expanded region, and the same line twice, both load. + +### a_cycle_among_group_parents_is_refused_naming_it + +`> Q1-2025[|2025|]` and `> |2025|[Q1-2025]`. Rival: the cycle check disabled; +only this test fails. The fixture's first version spelled `2024-01` bare, which +the grammar refuses (a bare name starts with a letter), so every test on it +passed or failed for the parse error, `a_malformed_depth_is_a_parse_error` +vacuously. It now carries a control that parses. + +### an_axis_line_for_a_category_named_expanded_is_still_an_axis_line + +`axis_line` is tried before `expanded_line`, so the writer must never spell a +category depth bare. Rival: the writer writing `expanded: Cat` for depth 1. This +test fails: the category `row` at depth 1 reads back as an axis line for a +category named `expanded`. + +### an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines + +"Everything opens closed, including old files." Every group of the file opens +closed, the one its `collapsed:` line names and the three it does not, and the +line is written back. Rival: the old open default; this test fails with the +other eleven in that rival's list. + +### a_date_column_imports_as_one_category_of_months_in_quarters_in_years + +improvise-8jk6.3, the user's "dates should usually be one category". The +fixture has two days of one month, so that month is one summed cell, and +January of the next year, so a month must stay unique across years. Rivals, each +run against the `date_hierarchy` module, and each caught here: +- The split as the default shape. +- Levels taken finest first, so the year hangs under the month. +- Groups registered without their parents. +- The raw dates still added beside the months. +- A hierarchy counted as `1 + components` slots. + +### a_level_left_unchosen_is_skipped_not_emptied + +Year + Month nests months straight into years, and Quarter alone is a flat +category of quarters. Caught the finest-first rival and the raw-dates rival. + +### an_imported_date_hierarchy_opens_folded_to_its_years + +The import sets nothing: the hierarchy opens folded, one row per year, because +every group starts closed (improvise-felo.6). The 2024 row is the fold of its +months, 111. Caught three of the rivals above. + +### an_unparsed_date_keeps_its_value_and_is_warned_about + +A format that reads none of the values keeps each raw value as an ungrouped +item, and the build says so. Its first version passed on the base commit: the +shape was ignored there and the SPLIT's warning happened to contain the same +words. It now also asserts there is no `Date_Year`. Rival: the unparsed value +not counted; this test fails. + +### a_headless_date_import_splits_when_told_to + +`--split-date` is the wizard's `s`, headless. It failed first as an unknown +argument. Rival: the flag parsed and ignored; this test fails, and +`a_headless_date_import_is_one_hierarchical_category` does not, which is the +point of having both. + +### every_fold_the_api_can_build_reads_back_from_the_file_it_writes + +ChatGPT Codex, P2 on #317 at `e09ec752`: `GroupFold::Expanded(usize)` and +`View::set_group_fold` are public, so `Expanded(0)` could be built, written as +`expanded(0): …` and then refused by the grammar that wrote it. Confirmed by +reading: the writer formatted any `usize`, and `depth_number` starts at `1`. +The fix makes the state unrepresentable (`Expanded(NonZeroUsize)`), so the +regression guard is the type, and the old failing input no longer compiles. +This test covers every fold the API CAN build (`Collapsed` and depths 1 to 3, +beside a category depth), each saved and read back. + +### a_record_with_no_value_for_a_category_is_kept_without_that_coordinate + +improvise-8jk6.2, reported by the user from the wizard: "Skipped 6 of 342 +records missing category values: Settlement Date (6)". `Plan::place` returned +`Placement::Missing` for a category with no value, so the build dropped the +whole record, measures and all. The fixture has one complete record, one with +`"Settlement Date": null` and one with `""`, for two different payees. At +`e65d87fd`'s parent the build stored only the first cell. Now the stored cells +are exactly the three records, the two blanks with no date coordinate, and +there is no warning. `Settlement Date` holds only its one real item, so neither +`"null"` nor `""` became one. Rival R1, a record with a present-but-blank +category treated as unplaceable, fails this test and the two improvise-ych +tests. + +### files_with_different_headers_import_as_one_sparse_model + +improvise-lpzu. code-maat's `age.csv` (`entity,age-months`) and `coupling.csv` +(`entity,coupled,degree,average-revs`) merged through `merge_csvs_from`. Before +the fix, the age records had no `coupled` key, so they took the same `Missing` +path and **every age record was dropped**. The build stored the four coupling +cells and nothing else. The test pins the proposals (each column's kind, +judged over its present values), all six stored cells sorted, no empty-named +item in any category, and no warning. Rival R2, a record whose file lacks a +category column skipped, fails it along with the two CLI folder tests, the +projection test and the per-file view test. + +### a_label_column_a_record_lacks_is_no_coordinate_not_an_empty_item + +improvise-lpzu, the label half. Labels were attached with +`scalar_name(..).unwrap_or_default()`, so an age record, whose file has no +`coupled`, stored `coupled=` as a coordinate, and `coupled` grew an item named +`""`. Before the fix the first sorted cell was +`File=age, _Measure=age-months, coupled=, entity=src/a.rs = 2`. Rival R3 +(`unwrap_or_default` restored) fails it. + +### a_folder_expands_to_its_csv_files_in_name_order_and_skips_the_rest + +improvise-lpzu. The folder holds three CSVs written out of name order, one +with an upper-case `.CSV`, plus a `.txt`, an `.improv` (the output a second run +finds) and a CSV in *another* folder. Only the three are returned, sorted. +`MemoryFs` stores in a `HashMap`, so the order comes from `Storage::list`'s +contract and not from insertion. Rival R5 (the extension filter dropped) and +rival R6 (the listing passed through a `HashSet`) each fail it. + +### a_folder_with_no_csv_files_is_refused_by_name + +improvise-lpzu. Both an empty folder and a folder holding only non-CSV files +must refuse with `no CSV files in folder maat`. Importing nothing would write +an empty document. Rival R4 (the `ensure!` removed) fails it, and so does R5, +because the text-only folder then "has" files. + +### files_and_folders_expand_in_argument_order + +improvise-lpzu. `[z.csv, b/, a.csv]` must expand to +`[z.csv, b/one.csv, b/two.csv, a.csv]`. Argument order is kept, and a folder +is sorted within itself. A fixture listing only folders, or files in name +order, could not tell "argument order" from "sort everything". R6 fails it. + +### two_files_with_one_stem_union_their_columns + +improvise-8jk6.12 (3). `a/x.csv` (`entity,one`) and `b/x.csv` (`entity,two`) are +one `File` item, `x`, whose columns are `entity, one, two`. Rival F3 (the +reviewer's fault: clear the stem's columns before each file) leaves +`entity, two`, and the `x` view would then hide the measure `one` that half its +records carry. + +### files_with_different_headers_merge_without_inventing_the_missing_columns + +improvise-lpzu, at the parser. Each merged record carries exactly its own +file's keys plus `File`: no key, `null` or `0` for a column another file has. +Each `SourceFile` lists its file's columns in header order. The per-file views +are built from those lists. + +### a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid + +improvise-8jk6.2's downstream question, asked of the real layout rather than +argued. Bolt has no `Date`: + +- With `Date` on Column or on Page, the grid draws only Acme's `10`. +- With `Date` on None, it draws both `10` and `20`. +- In records mode, Bolt's row reads `["", "Bolt", "", "20", ""]`, a blank + `Date` cell. +- The Data line is `Payee=Bolt, _Measure=Amount = 20`, and it round-trips. +- A drill on `Payee=Bolt` reaches the record. + +This is a characterization of the model's existing sparse semantics. It found +no bug. Its purpose is to make the Row/Column answer ("invisible") a stated +fact the user can overrule, rather than an accident. R2 fails it. + +### a_merge_gives_each_file_a_view_projected_to_its_columns + +improvise-lpzu, the user's "one view filtered by the file column and +projected to the columns in that file". The test asserts: + +- the view order (`Default`, then the files in read order), with `Default` + still active; +- `File` on Filter at the file's item; +- the file's own categories seated, and the other file's on None; +- `_Measure` on Page at the file's first measure, with the other file's + measures hidden; +- the drawn values. + +The age view draws `[2, 7]`. The coupling view draws `[5, 3]`, because its +first measure in model order is `average-revs`: serde_json's map orders keys +alphabetically. Failed at `bb505f8f`'s parent, where no view existed. Rivals +R7 (File on None), R9 (every category seated) and R10 (nothing hidden) each +fail it. + +### a_files_view_holds_only_that_files_records + +improvise-lpzu. The code-maat fixture cannot show that the *filter* works, +because its files share no measure, so the measure page alone would isolate +them. Two quarterly files with the same `Region,Revenue` schema can: without +the filter, `q1` would draw `30` and `3`. Rival R7 fails it. R9 and R10 do +not, and are not meant to. + +### a_files_view_name_skips_names_already_held + +improvise-lpzu. Two stems collide with names already held: `Default`, the +merged view, and `_Drill`, a virtual view that is never written and is dropped +on load. The first becomes `Default 2` and the second `_Drill 2`, and each +still filters to its own item. Rival R8 (the virtual names not checked) fails +it: the file's view would have been saved as `_Drill` and lost. + +### per_file_views_survive_a_round_trip_and_repeat_byte_for_byte + +improvise-lpzu. Two builds from two independently written `MemoryFs` give +identical `.improv` text, and `format(parse(text)) == text`. The reloaded +document still has the three views in order, `File: filter` at the file's item, +and the same drawn values. This is the first persisted, non-virtual use of +`Axis::Filter` (gotcha 27). R7 and R9 fail it. + +### a_csv_column_read_as_a_category_keeps_its_text_and_as_a_measure_its_number + +improvise-8jk6.4, at the pipeline. It checks three things in one fixture, +one per reader of a CSV value: + +- analysis still proposes the numeric-looking `commit` as a Measure (typed); +- forced to Category, its items are `00565796` and `220944e2` (text); +- `lines` is stored as the number 5 (typed). + +The rivals, each run: + +- **R1**, a category named from the typed value, fails it on the items. +- **R2**, a CSV measure read with `as_f64` on its text, fails it on the cell. +- **R3**, CSV analysed without typing, fails it on the proposal. + +R2 and R3 also fail the five lpzu merge tests in this module and five +headless imports. That is how much of the suite leans on CSV measures reading +as numbers. + +This test uses `from_csv` and so cannot compile on the base. The base failure +for 8jk6.4 is the headless test +`a_csv_column_forced_to_category_keeps_its_text_verbatim`. + +### a_formula_that_does_not_parse_stops_the_build_and_is_named + +improvise-8jk6.5. On the base the build returned `Ok`, keeping `Good` and +silently dropping `Bad`. Two formulas fail to parse here, so the test proves +every failure is named. **R5**, naming only the first, fails it on `Worse`, +and since improvise-8jk6.17 the headless +`an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing` +sees R5 too (it passes two bad formulas). It also asserts `Good` is not named, so a +message that lists every formula cannot pass. **R4** (drop, as the base did) +fails it. + +### confirming_a_formula_that_does_not_parse_keeps_it_out_and_the_text_in + +improvise-8jk6.5, the wizard half. On the base `confirm_formula` pushed +`Bad = revenue +` into the list, which failed on `pipeline.formulas`. The test +asserts the text is still in the buffer and the editor still open. A fix that +refused but cleared the buffer would throw away what the user typed, the same +hazard improvise-v4hg fixed in the TUI formula minibuffer. **R6**, confirming +regardless, fails it. The headless suite cannot see R6. + +### a_build_that_keeps_no_record_refuses_and_says_why + +improvise-8jk6.6. On the base both halves got `At least one category must be +accepted`, a refusal right only by accident. It asserts the exact message for +the two cases `nothing_to_import` distinguishes: all skipped, and no records +at all. + +- **R7**, the check removed from the build, fails it. +- **R9**, `any` swapped for `all`, also fails it, on the empty source: `all` + is vacuously true over no records. + +### the_review_step_says_when_no_record_can_be_imported + +improvise-8jk6.6, the wizard half (gotcha 22: the check lives on the step +where the user can still choose another array path). On the base the step +refused with `No categories: … Press c to cycle a field's kind`, which offers +a key that has no field to act on. **R8**, the step without the check, fails +it. The build test does not see R8. + +### a_header_only_file_keeps_its_file_item_its_columns_and_its_view + +The ChatGPT Codex P2 on PR #314 at `4e68372c`, confirmed by the adversarial +review. `age.csv` (one row), a header-only `coupling.csv` and `rev.csv` (one +row). At `005b7a76` the test failed at the first assertion: `File` held only +`age` and `rev`, so the `coupling` view filtered to an item that did not exist, +and `coupled`, `degree` and `average-revs` were not in the model. Now `File` +reads `age, coupling, rev` in file order, the three columns exist (as Labels), +and the `coupling` view is a zero-row records view filtered to `coupling`, while +the `age` view still draws `2`. Rival F5 (sources not seeded) and F6 (unseen +columns not proposed) each fail it, and so do F7 and F8 below. + +### a_file_with_no_measure_gets_a_records_view_not_another_files_measure + +improvise-8jk6.13, from the review of PR #314. `a.csv` (`entity,x`) and +`tags.csv` (`entity,tag`). At `005b7a76` the `tags` view had `_Measure` on Page +with no selection, so the layout paged onto `x`, a measure the same view hides. +The test failed there, with `row_count() == 1`: `a`'s record, drawn under +`File=tags`'s view. Now the view has no `_Measure` page, is a records layout, +and draws zero rows, while `a`'s view is still a pivot. Rival F7 (the old +paging) fails it, and F8 (the File selection lost when `records_steps` moves +`File` off Filter) fails it with the same `1`. F8 is the mistake the first +version of the fix made. + +### a_files_view_seats_the_date_parts_of_its_own_date_column + +improvise-8jk6.12 (1). The reviewer replaced the date-parts clause of +`source_view`'s `own` with `false` and every test passed, because no folder +fixture extracted a date part. Here `abs-churn.csv` has a `date` column, +extracted to `date_Month`, and `age.csv` does not. `date_Month` is seated in the +`abs-churn` view and on None in the `age` view. Rival F1 (the reviewer's fault) +puts it on None in both and fails the first assertion. + +### two_files_whose_names_collide_in_one_batch_keep_a_view_each + +improvise-8jk6.12 (2). `Default.csv` and `Default 2.csv`. The first takes +`Default 2`, so the second must take `Default 2 2`. Without the same-batch +check both are named `Default 2`, and `IndexMap::insert` silently replaces the +first, leaving two views where three are owed. The test asserts all three names +with their filters. Rival F2 (the reviewer's fault) fails it. + +### a_whitespace_only_category_value_is_no_coordinate + +improvise-8jk6.12 (4). A `region` of `" "` is kept as a record with no +`region` coordinate, and `region` holds only `East`. The trim in `scalar_name` +predates this PR (improvise-ych), but the sparse-record rule now rests on it: +without it the value becomes an item named `" "`. Rival F4 (the reviewer's +fault) fails it. + +### a_raw_date_spelled_like_a_generated_item_is_refused + +improvise-d0n6 (Codex P2 on PR #322). A `%Y-%m-%d` column imported Year > Month +with `2024-01-15` and a raw `2024-01`. Before the refusal the build succeeded, +put both records on the grouped item `2024-01` (summing them) and warned that +the raw value was "kept ungrouped", which was false. Fault injected: removing +the `spelling_clash` call makes this test fail with `unwrap_err()` on an `Ok`. + +### a_raw_date_spelled_like_a_generated_group_is_refused + +improvise-d0n6. The same with a raw `2024`, a generated group. Removing the +`spelling_clash` call fails it the same way. The control, +`a_raw_date_spelled_like_nothing_generated_is_kept_ungrouped`, passes either +way, so a blanket refusal of every unparsed value fails it. + +### the_date_step_will_not_advance_over_a_spelling_clash + +improvise-d0n6 (Copilot on PR #322, and the adversarial review's B1). The +spelling-clash refusal sat only in the build, so a wizard with a raw `2024` in +a `%Y-%m-%d` column passed ConfigureDates and failed on NameModel, three steps +from the `s` that fixes it. Before the check joined `validation_error` the +test failed with the step already `DefineFormulas`; removing that call again +reproduces it. It also toggles to the split shape and requires the step to +advance, so a check that blocks forever fails. + +### split_date_naming_an_unknown_or_non_date_field_is_refused + +improvise-tpm8 class. `--split-date Nope` and `--split-date Revenue` (a +measure) exited 0 and did nothing. Both now exit 1 naming the flag and the +field; removing the check in `run_headless_import` fails both rows. + +### an_axis_naming_a_category_the_import_did_not_build_is_refused + +improvise-8jk6.3 review. `--extract Date:Month --axis Date_Month:column` used +to build one `Date` category and drop the `--axis` for the vanished +`Date_Month`, exit 0. It now exits 1 naming the category and `--split-date`. +This also refuses any `--axis` naming an absent category, which was silently +ignored before this PR as well. + +### a_folder_of_only_header_only_files_keeps_no_record_and_refuses + +improvise-8jk6.6 meeting PR #314's rule that a header-only file is still a +file. Written after merging `main` at `c91cc055`, where the two rules first +met. The file seeds give the pipeline a `File` Category proposal even with no +records. So without `nothing_to_import` the build succeeds and saves `File` +items and empty views: the category check alone cannot refuse. The `setup` +assertion proves that seeded proposal exists, so the test is not passing only +because there was no category. **R7**, the check removed from the build, fails +it. The user confirmed the refusal on PR #321: "Refuse (as #321 does)". + +### an_absent_axis_category_that_is_no_date_part_names_what_was_built + +improvise-8jk6.3 review (B2). The README's own recipe passed +`--axis Measure:column`, a silent no-op because the category is `_Measure`; the +first refusal blamed dates for it. The test uses a non-date absent name and +asserts the message lists the built categories and carries no date hint. +Fault injected: making the hint unconditional fails it. + +### the_readmes_import_recipe_runs + +improvise-8jk6.3 review (B2). Runs the README's recipe as printed. Fault +injected: restoring `--axis Measure:column` in the README fails it with the +refusal. + +### split_date_on_a_time_field_with_no_date_format_is_refused + +improvise-8jk6.3 review. `--time Revenue --split-date Revenue` made the field a +time category with no date format and the split was a silent no-op. Without +the `date_format.is_some()` condition the import exits 0 and this fails. diff --git a/context/test-evidence/layout-cost.md b/context/test-evidence/layout-cost.md new file mode 100644 index 00000000..e51fad20 --- /dev/null +++ b/context/test-evidence/layout-cost.md @@ -0,0 +1,125 @@ +# Layout and keypress cost (improvise-felo.13) + +Measured on an **Apple M4 Max, 64 GB**, macOS, release build, with +`benches/scale_bisect.rs` (merged from `bench/scale-bisect`, `13918bb9`), one +configuration per process, 3 categories of K items on Row, N = 100,000 capped at +K^3/2 records, prune off unless stated. Baseline is this branch with only the +tests added; "after" is the branch head. `key` is `App::dispatch_key(Down)`; +"cold" is the first key after open or after an edit, which pays the one +whole-grid measurement; "warm" is every key after it. + +## Before and after + +| rows | before key | after key warm | after key cold | before browser window | after browser window | SetCell before / after | +|---|---|---|---|---|---|---| +| 729 (K=9) | 5.5 | 0.00 | 1.4 | 5.0 | 0.13 | 0.9 / 0.9 | +| 4,913 (K=17) | 108 | 0.00 | 6.0 | 105 | 0.12 | 5.8 / 5.9 | +| 5,832 (K=18) | | 0.00 | 7.7 | | 0.12 | 6.8 | +| 6,859 (K=19) | | 0.00 | 8.8 | | 0.12 | 8.6 | +| 19,683 (K=27) | 1,271 | 0.00 | 24.5 | 1,259 | 0.14 | 23.0 / 22.7 | +| 64,000 (K=40) | 10,771 | 0.00 | 84 | 10,746 | 0.20 | 79 / 80 | + +Prune on: K=17 (2,456 rows) key 264 ms -> 0.00 warm, 3.6 cold; prune toggle +172 -> 8.8 ms; K=27 prune toggle 2,189 -> 41.5 ms. Records mode (3 x 1000, 14,500 +records): key 1,004 ms -> 0.00 warm, 2.8 cold; **SetCell 1,511 ms -> 1,512 ms, +unchanged** (not this ticket's cost). + +Boundaries, warm key and browser window: inside 8 ms at every size measured (to +216,000 rows, 303 ms cold). Cold key: inside 8 ms to K=18 (5,832 rows), over 8 ms at +K=19. Before: inside 8 ms to 729 rows, over 100 ms at 4,913, over 1 s at 19,683. +The remaining cold cost is linear (1.2-1.4 us per row) and is the whole-grid width +and total pass; the warm path does not repeat it. + +## What the ticket's reading got right and what it did not predict + +The O(rows^2) in `cell_key` was real and is one of three causes. Two more sat +under it. First, `compute_col_widths` ran on **every** keypress, through +`cmd_context`, and `dispatch_key` and `apply_effects` each rebuilt the layout +whether or not anything had moved. Second, once the index was O(1) the width pass +was still O(K^2) per cell: with `_Index`/`_Dim` on `Axis::None` the aggregation is +not `Exact`, so each cell went through `DataStore::matching_values`, which +narrows by the smallest index set (about N/K candidates) and probes the rest. A +partial as wide as the widest stored cell can match only one cell, so +`exact_positions` probes it directly. That second cause is why K=40's cold key +stayed at 8 s after the first two fixes. + +### a_cursor_key_rebuilds_neither_the_layout_nor_the_widths + +`ui::app::tests`. Two keys after a warm-up leave `layout_builds` and +`width_passes` where they were. Failed before the fix on the layout count (a +cursor key rebuilt it). Rival 1 (`refresh_layout` always rebuilding): fails on +`layout rebuilt on a cursor key`. The test seats the cursor at row 2, so "the +keys moved the cursor" is asserted and a key that does nothing cannot pass. + +### an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value + +The control for the test above, and the guard on invalidation. A `SetCell` of a +long text must rebuild the layout and remeasure, and the cached widths must equal +a fresh `compute_col_widths`. Rival 2 (`rebuild_layout` not dropping the cached +geometry): fails on `an edit must remeasure`. It also asserts the widths changed, +so a fixture where the edit does not widen a column cannot pass. + +### switching_view_rebuilds_though_the_effect_is_view_only + +`SwitchView` is `ViewOnly` (gotcha 3), so skipping the rebuild by `EffectKind` +alone would leave the old view's layout. The layout records the view it drew and +the drill snapshot it froze, and rebuilds when either differs. + +### an_axis_lookup_does_work_independent_of_the_row_index + +`view::layout::lookup_cost_tests`. Counts the items `data_items` yields during +`cell_key`, `row_tuple`, `row_label` and `column_key`; row 399 of 400 must cost no +more than row 0 plus two. Failed before the fix: `cell_key: row 399 cost 402 steps +against 3 at row 0`. Rival (`cell_key` going back to `data_items(..).nth(row)`): +`cost 400 steps against 1 at row 0`. Limit: the counter lives in `data_items`, so +a different linear walk written without it is not seen; the next test is what +holds the index to the walk's answers. + +### the_position_index_answers_what_the_walk_answered + +Compares `row_tuple`, `data_row_to_visual` and `row_folds_a_group` against the +walk over a grouped, collapsed and pruned axis, including two indices past the +end. Rival (`data_index_to_visual` reading `data_idx + 1`): fails at `visual 0`. + +### a_full_width_partial_probes_one_cell_instead_of_narrowing + +`model::cell::exact_path_tests`. A full-width partial must not reach set +narrowing, and an absent cell and an unseen item must match nothing. Rival +(`exact_positions` returning `None`): the narrowing count is 1, not 0. Rival +(`widest <= partial.len() + 1`): fails `a_wider_cell_turns_the_shortcut_off_until_it_is_removed`, +which stores a cell one coordinate wider and expects it matched. +`a_narrower_partial_still_narrows_and_matches_every_superset` is the control: the +shortcut is not a blanket exact lookup. `an_unsorted_full_width_partial_is_still_answered` +holds the canonical-order condition: a partial not in a stored key's order falls +back. + +### a_window_over_a_held_summary_equals_the_one_walk_grid_body + +`ui::viewport::tests`. The host keeps one `GridSummary` (widths, total formats, +totals) per layout and reads a window over it with `grid_window`; it must equal +what `grid_body` builds for the same window, over the whole grid, an interior +window, the last row and the empty window. Rival (`grid_window` reading column +`ci + 1`): fails at `window 0..2 x 0..10`. Widths stay the **whole-grid** maximum, +because the summary is the same walk `grid_body` did; nothing the user sees moves. + +### a_frozen_records_snapshot_replaces_the_live_row_index + +`view::layout::lookup_cost_tests`. Three records are frozen, two are then cleared, +and `with_frozen_records` over the snapshot must draw 3 rows (the control: with no +snapshot it draws 1). A drill held open while another client removes records is the +production analogue. Rival (deleting `layout.reindex()` after the snapshot swap): +fails `left: 1, right: 3`; the whole workspace passed it before this test. + +### a_drill_snapshot_replaced_alone_rebuilds_the_layout + +`ui::app::tests`. `StartDrill` is `ViewOnly`, so a batch of it alone has no `Model` +effect to mark the layout stale; the drill pointer in `LayoutBasis` is the only +thing that rebuilds it. The test starts one snapshot, then a second: the first +step alone passes even with a constant pointer (drill absent to present changes the +basis), which is why the second step is the test. Rival (pointer replaced by a +constant): fails `no rebuild for a second drill snapshot`. + +### Observation: the TUI frame + +`tui_frame_grid_of_whole` is still 47-53 ms at 19,683 rows after this change: the +draw path projects the whole grid, linear and out of scope for this ticket. diff --git a/context/test-evidence/protocol.md b/context/test-evidence/protocol.md index 8b1ead78..a04e0deb 100644 --- a/context/test-evidence/protocol.md +++ b/context/test-evidence/protocol.md @@ -1259,3 +1259,51 @@ the Enter commit. - **Rival** (the commit naming `state.view.active_view`, the view at `Enter`), run across the whole workspace: fails **only** this test, with `["Later"]` against `["Other"]`. + +### resolution_takes_each_attribute_from_the_nearest_face_that_states_it + +ADR 004 clause 3: inheritance resolves once, in shared code. The fixture states +a different attribute at each of three levels — `default` (foreground White, +italic), `mode` (foreground Black, background Blue), and +`mode.text-entry.save` (background `Colour::Default`, underline) — so the one +resolved face has to take its foreground from the middle, its slant from the +root and its background from itself. `Some(Colour::Default)` is the case that +matters: it is a stated value and must not fall through to `mode`'s Blue, which +the sibling `mode.text-entry.export` (stating nothing) does inherit. + +**Injected fault**: fold the chain so the parent wins. This test fails, and so +does the TUI's `the_shipped_theme_reproduces_every_migrated_literal`, because +`mode.text-entry.edit` then draws its parent's DarkGray. + +### every_axis_gets_its_own_accent_in_the_shipped_theme + +Moved from `improvise-core`'s `every_axis_gets_its_own_color` when `AxisColor` +left core (ADR 004 clause 6). Distinct axes must be distinguishable at a +glance, and the axis faces exist for exactly that, so two axes resolving alike +defeats them. It now asks the shipped theme rather than an enum's arms, which +is the stronger question: two distinct `AxisColor` arms could always have been +mapped to one terminal colour. + +### a_theme_that_restyles_text_entry_reaches_every_minibuffer + +ADR 004 says `minibuffer.*` inherits `mode.text-entry` directly. PR #319's first +head gave the minibuffer faces a `minibuffer` base face instead, and ChatGPT +Codex flagged it (P2): face parents are compatibility behaviour once a theme +can be downloaded, so a theme that restyles `mode.text-entry` must reach every +prompt. The test overrides only `mode.text-entry` with italic, an attribute no +minibuffer face states in the shipped theme, and asserts every +`minibuffer.` resolves italic. A test that sets a colour instead would +be vacuous, because each minibuffer face states its own colours. + +**Failed first**, against the `minibuffer` base parent: `Edit` resolved to +`Normal`. It passes under the accepted chain. The no-op oracle +`the_shipped_theme_reproduces_every_migrated_literal` passed both before and +after the change, which shows the change moved no pixel. + +### a_quarters_end_leaves_its_year_open_on_the_same_level + +improvise-felo.6: runs inside one category nest by `(level, depth)`. `Direct` +sits in `2024` after `Q1`'s `GroupEnd { level: 0, depth: 1 }`, and `Undated` +in nothing after `2024` ends. Rival: `groups()` closing by level alone, as it did +before depth existed. Only this test fails in `improvise-protocol`: every other +frame fixture puts one group per level. diff --git a/context/test-evidence/scale-bisect.md b/context/test-evidence/scale-bisect.md new file mode 100644 index 00000000..7bf797d6 --- /dev/null +++ b/context/test-evidence/scale-bisect.md @@ -0,0 +1,218 @@ +# Scale bisect: how big a document does improvise handle today (improvise-felo.10) + +Measured on `gh/main` @ 8220a065, **Apple M4 Max, 64 GB** (`sysctl`: `hw.memsize 68719476736`), macOS, +release build (`lto`, `opt-level 3`), native only (no wasm, no real OPFS, no terminal). +Harness: `benches/scale_bisect.rs` (one configuration per process) driven by `bench/scale_bisect.py`, +`bench/sweep.py`, `bench/edge.py`, `bench/full.py`. Reproduce: +`nix develop --command cargo build --release --bench scale_bisect`, copy the binary from +`target/release/deps/scale_bisect-*` to `/tmp/sb`, then `python3 bench/edge.py`. + +Budgets (user ruling 2026-10-03): **8 ms is the headline** (120 FPS, hot path or defer to background); +16 ms, 100 ms (noticeable) and 1 s (broken) are reference crossings. + +## Method, and what is real + +Every number goes through the production path: `App::new` (open: formula recompute + +`GridLayout::with_frozen_records`), `App::dispatch_key(Down)` (**keypress/scroll**: it calls +`rebuild_layout`, then `cmd_context`, which runs `compute_col_widths` over the whole grid, then applies effects), +`project::grid_of` (TUI frame: whole grid; browser: a 45x12 window), `App::apply_effects` with `effect::SetCell`, +`SetAxis`/`HideItem`/`ToggleGroup`/`TogglePruneEmpty` (reshape), `Host::connect` and `Host::handle` with a +`caught_up` `ModelCommand::SetCell` (browser path incl. projection), `format_md`/`parse_md`/`save_to(MemoryFs)`. +Document: C0..C{cats-1} with K items, N distinct random tuples ("records", capped at K^cats/2), two measures +each (2N cells), `_Measure` on Column. Timings are the **median** of up to 3-8 in-process repeats; boundary +rows are the median of **3 separate processes** run sequentially (spread shown). Earlier exploratory sweeps +ran 2-3 processes in parallel and agree with the sequential runs within ~5%. + +**`View::prune_empty` defaults to false** (`view/types.rs`), so "prune off" is what a user gets until they +toggle it; "prune on" is reported next to it. Rows below are the layout's `row_count`. + +**Dominant cost (reasoned, not profiled):** the release profile strips symbols, so `sample` could not +symbolicate. The reason is in the code and the timings agree with it: `GridLayout::cell_key`/`row_item` do +`data_items(&self.row_items).nth(row)` (`view/layout.rs` ~253/292/807/870), O(row) per lookup, and +`compute_col_widths` (every keypress, via `cmd_context`) and `grid_of` visit every cell, so both are +O(rows^2). Evidence: with prune off, rows 729 -> 5.4 ms, 4096 -> 74.6 ms, 19683 -> 1248 ms (x6.7 rows -> +x14 time -> x17 time). A 45x12 browser window costs **the same** as the whole-grid TUI frame (K=30: +2274 vs 2298 ms) because the widths pass is whole-grid whatever the viewport says, so viewport narrowing buys +nothing today. `GridLayout` build itself is cheap (~90 ns/row, ADR 008). + +## Attribution: state+projection vs render/serialize (K=10, 1000 rows, prune off; 3 runs) + +| step | ms | +|---|---| +| keypress (state: rebuild + widths + effect) | 9.5-9.7 | +| `grid_of` whole grid (TUI projection) | 11.7-12.0 | +| `GridWidget` render into a 200x50 ratatui `Buffer` | 0.12 | +| `grid_of` 45x12 window (browser projection) | 9.1-9.3 | +| JSON encode of one SetCell's projected updates | 0.001-0.005 (0.6-1.1 KB) | +| JSON encode of the connect Snapshot | 0.02 (21 KB) | + +Render and serialize are **<=0.5 ms in every row measured** (GridWidget 0.08-0.53 ms up to 14,500 records; +snapshot encode <=0.07 ms; records-mode SetCell updates 21 KB in 0.02 ms). The 8 ms is entirely state and +projection. Not measured: the rest of `draw()` (panels, status; `draw` is private to the binary), terminal I/O, +browser JSON *decode* and DOM paint. + +## Axis B: items per category K, 3 categories on Row, N = 100k (capped at K^3/2), prune OFF + +| K | rows | open | keypress | browser window | SetCell | maxrss MB | +|---|---|---|---|---|---|---| +| 5 | 125 | 0.1 | 0.5 | 0.5 | 0.2 | 8 | +| 9 | 729 | 0.2 | **5.4** (5.3-5.5) | 5.0 | 0.9 | 13 | +| 10 | 1,000 | 0.2 | **9.6** (9.5-9.7) | 9.2 | 1.2 | 14 | +| 11 | 1,331 | 0.3 | 12.7 | 12.7 | 1.6 | 10 | +| 12 | 1,728 | 0.3 | 20.0 | 19.3 | 2.1 | 11 | +| 15 | 3,375 | 0.5 | 65 | 64 | 4.0 | 14 | +| 16 | 4,096 | 0.6 | 74.6 (74.3-75.2) | 73.4 | - | - | +| 17 | 4,913 | 0.7 | **105** (105.0-105.5) | 103 | - | - | +| 20 | 8,000 | 1.1 | 302 | 301 | 9.4 | 21 | +| 25 | 15,625 | 1.9 | 773 | 771 | 18.8 | 33 | +| 26 | 17,576 | 2.2 | 967-977 | 962 | - | - | +| 27 | 19,683 | 2.4 | **1,248** (1,244-1,251) | 1,244 | - | - | +| 30 | 27,000 | 3.1 | 2,262 | 2,274 | 31 | 49 | +| 40 | 64,000 | 6.7 | 10,858 | 10,833 | 73 | 235 | +| 60 | 216,000 | 21.6 | timeout (>60 s) | - | - | - | +| 1000 | 10^9 | **refused** (see below) | | | | | + +Hot-path keypress/scroll and browser window: **largest inside 8 ms: K=9 (729 rows)**; inside 16 ms: K=11; +first over 100 ms: **K=17 (4,913 rows)**; first over 1 s: **K=27 (19,683 rows)**. SetCell alone +(no widths pass; layout rebuild only, ~linear in rows): inside 8 ms to K~19 (K=15 4.0, K=20 9.4), 16 ms ~K=24, +100 ms ~K=45 (K=40 = 73 ms), 1 s extrapolated ~K=95 (**extrapolated, not measured**). Open stays <25 ms to K=60. + +**3 x 1,000 on Row cannot be opened at any N**: the layout materializes the cross product (10^9 rows, ~150 B +each = ~150 GB, ADR 008). The harness refuses above 30M rows (`rows_cap`) rather than swap the machine, so +the 10^9 number is the ADR's estimate, not a measurement here. + +### Prune ON (same documents) + +| K | rows | open | keypress | browser window | SetCell | +|---|---|---|---|---|---| +| 7 | 171 | 2.0 | 4.5 (4.4-4.6) | 1.1 | 5.4 | +| 8 | 256 | 3.0 | **7.0** (7.0-7.1) | 1.6 | 8.5 | +| 9 | 364 | 5.3 | 12.8 | 2.7 | 15.3 | +| 11 | 665 | 12.2 | 29.6 | 6.2 | ~28 | +| 13 | 1,098 | 29.0 | 71 | 14.5 | ~70 | +| 14 | 1,372 | 48 | **119** | 24 | - | +| 20 | 4,000 | 300 | 747 | 145 | 903 | +| 21 | 4,630 | 401 | 968-992 | 190 | - | +| 25 | 7,812 | 767 | 1,899 | 366 | 2,328 | +| 30 | 13,500 | 2,255 | 5,555 | 1,047 | 6,804 | + +Prune on: keypress inside 8 ms to **K=8**, first over 100 ms **K=14**, ~1 s at **K~21-22** (K=22 measured +0.93-0.95 s, a different N cap; first clearly over: K=25). Pruning shrinks the grid, so the browser window is +cheaper than prune off at the same K (K=20: 145 ms against 302 ms), but **every keypress re-runs +the prune** (`dispatch_key` calls `rebuild_layout`), so keypress and SetCell are *worse* with prune on +(K=10: 23 vs 9.9 ms). Prune is also quadratic (`nth`). Conclusion: prune on/off moves the boundary by about +one K at 8 ms and makes 100 ms/1 s *earlier* (K=14/21 vs 17/27); it never rescues the 3x1000 case. + +## Axis A: records N at fixed K (K=30, 27,000 rows) + +| N | prune off keypress | prune on rows | prune on keypress | prune on window | +|---|---|---|---|---| +| 100 | 451 | 100 | 861 | 0.3 | +| 1,000 | 527 | 1,000 | 1,059 | 6.4 | +| 5,000 | 1,013 | 5,000 | 2,178 | 140 | +| 13,500 (cap) | 2,267 | 13,500 | 5,555 | 1,047 | + +Prune off: ~450 ms of widths over 27k empty rows + ~1.3 us per stored cell; **N never gets the keypress +under 100 ms at K=30**, and at K=1000 N is irrelevant because the layout cannot be built (rows = K^3). +The N-axis has no bisect point in the 3x1000 regime; N only matters once K is small enough that rows fit, and +there it is a second-order term. Open is flat (3.1-3.6 ms) in N with prune off. + +## Axis C: categories on Row (prune off) + +| K | cats | rows | open | keypress | note | +|---|---|---|---|---|---| +| 10 | 2 | 100 | 0.1 | 0.2 | | +| 10 | 3 | 1,000 | 0.2 | 9.9 | over 8 ms | +| 10 | 4 | 10,000 | 1.4 | **880** | ~1 s | +| 10 | 5 | 100,000 | 14 | timeout >100 s | | +| 3 | 6 | 729 | 0.3 | 27.9 | 5x worse than K=9 x 3 cats at the same rows | +| 3 | 8 | 6,561 | 2.0 | 2,798 | | +| 3 | 10-12 | 59k-531k | 18-210 | timeout | open alone passes 100 ms at 11 cats | +| 2 | 10 | 1,024 | 0.6 | 172 | | +| 2 | 12 | 4,096 | 2.2 | 3,190 | | +| 2 | 14 | 16,384 | 9 | 63,100 | | + +The cap of 24 is unreachable: rows = product of items, so a view with c Row categories of K items dies by +K^c ~ 10^4 rows (1 s) regardless of N, and with two items per category by 12-14 categories. Per-row cost +also grows with category count (each row_item is a Vec of c items). Not measured: Row categories with prune +on, other axes absorbing categories (the realistic mitigation: only 1-2 on Row). + +## Axis D: records mode over N records (`with_frozen_records`, the improvise-sk32 quadratic) + +| N | open | keypress | browser window | SetCell (apply) | host SetCell+project | maxrss MB | +|---|---|---|---|---|---|---| +| 300 | 1.5 | 0.7 | 0.1 | 1.6 | - | 10 | +| 1,000 | 2.9 | 5.6 (5.5-5.7) | 0.3 | 10 | - | 12 | +| 1,200 | 3.9 | **7.9** (7.7-8.0) | 0.3 | - | - | - | +| 1,300 | 4.5 | 9.3 | 0.3 | 15.5 | 17 | 26 | +| 1,800 | 8.4 | 16.5 | 0.4 | - | - | - | +| 3,000 | 22.9 | 47 | 0.7 | 74 | - | 20 | +| 4,400-4,600 | 47-51 | **95-102** | 0.9 | 149 | 158 | 55 | +| 10,000 | 248 | 492 | 1.8 | 734 | - | 46 | +| 14,000-14,500 | 472-500 | **940-1,002** | 2.6-2.8 | 1,435 | 1,459 | 135-148 | +| 30,000 | 2,198 | 4,314 | 5.6 | 6,467 | - | 125 | +| 100,000 | **25,420** | **51,234** | 19.3 | not reached (killed at 100 s) | - | not reached | + +Records mode, keypress/scroll: inside 8 ms **to N~1,200**; 16 ms N~1,800; 100 ms N~4,500 (first over: 4,600 = +102 ms); 1 s N~14,500. Open crosses 8 ms ~1,750, 16 ms ~2,300, 100 ms ~6,500, 1 s ~20,000. Time ~ N^1.95. +SetCell is ~1.5x keypress (it rebuilds and re-groups). The *browser window* is cheap (<20 ms even at 100k) +because records mode has two columns and the width walk is short, so the 51 s is `rebuild_layout`, not +projection. Prune on/off does not change records mode (prune applies to pivoted axes; the sweep ran off). +Records per KB: ~4 KB/record process-wide at 14k (maxrss includes the document generator, so read as an +upper bound); the 100k memory was not obtained. + +## Axis E: save / load and per-command persistence + +Mode `none` (all categories off-axis, so layout is trivial and only the store scales), 3 x 1,000, N records += 2N cells: + +| cells | format_md | text MB | parse_md | persist per accepted command (`save_to` MemoryFs) | maxrss MB | +|---|---|---|---|---|---| +| 2,000 | 13.8 | 0.1 | 4.5 | 12.4 | 18 | +| 20,000 | 156 | 0.86 | 37 | 161 | 80 | +| 60,000 | 527 | 2.5 | 119 | 539 | 236 | +| **200,000 (the target)** | **1,974** | 8.4 | **401** | **1,984** | 663 | +| 600,000 | 6,470 | 25 | 1,320 | 6,460 | 1,894 | +| 2,000,000 | 23,120 | 84 | 4,668 | 23,088 | 5,611 | + +Linear: ~10 us/cell to format, ~2 us/cell to parse. Crossings, format_md: 8 ms ~700 cells, 16 ms ~1.5k, +100 ms ~11k, 1 s ~100k cells. parse_md: 8 ms ~4k cells, 100 ms ~50k, 1 s ~450k. **At the target (200k cells) +every accepted command in the browser worker costs ~2 s of format alone** before OPFS write time (not +measured: MemoryFs only). Persist per command also appears in smaller documents inside the grid axes +(K=10 grid: 5.5 ms; records N=4,400: 63 ms). + +## Other operations (3 runs each; "grouped" fixtures start with C0 groups collapsed, so rows are not K^3) + +Reshape (prune off, K=10, 1000 rows): hide item 0.25 ms, set axis 0.19 ms, **toggle prune 12.7 ms**. +K=26 grouped, 2,028 rows: hide 0.5, toggle group 1.8, set axis 1.8, **toggle prune 727-750 ms**. +Prune on K=13 grouped, 321 rows: hide 9.4, toggle group 37, set axis 37, toggle prune 37-39 ms. +`Host::connect` snapshot: K=10 6.5 ms, K=26 375 ms, records N=14,000 2.9 ms. Host SetCell+project: K=10 7.7 ms, +K=26 ~400 ms, records N=4,400 158 ms (equal to apply + a whole-grid width walk). + +## Classification and feasibility of yielding + +| operation | class | today's first size over 8 ms | dominated by | chunked/yielding form | +|---|---|---|---|---| +| keypress / scroll | **hot (<=8 ms)** | 1,000 rows (K=10) / N=1,300 records | `rebuild_layout` + whole-grid `compute_col_widths` (O(rows^2) `nth`), recomputed though nothing changed | not a slicing problem: cache widths/layout across keys, O(1) row lookup (ADR 008/009). Slicing would only hide it | +| browser window projection | **hot** | 1,000 rows | same width walk, whole grid regardless of window | needs the same fix; then window cost is O(window) | +| SetCell apply + project | **hot** | K~19 (prune off), 1,300 records | layout rebuild O(rows) + widths + projection | incremental update of one row/total, not a rebuild | +| column totals | hot | inside `grid_of` (not separable here) | whole-grid sum in the projection | maintained total (ADR 009) | +| open / layout build | deferrable | 40k rows (prune off); N~1,750 (records) | build is ~90 ns/row (sliceable by outer index); the prune/records grouping is the quadratic part | build is feasible in slices once the quadratic is removed; the quadratic itself is not sliceable | +| reshape (hide/collapse/axis) | deferrable | cheap prune-off (<2 ms to ~2k rows); 37 ms prune-on at 321 rows | layout rebuild + prune | one sort/remap at 100k occupied tuples was 2.3 ms in ADR 008's prototype, so a reshape can fit inline; prune toggle needs the quadratic fixed first | +| save (format_md) | deferrable | ~700 cells | per-cell formatting, linear | the Data section is a flat per-cell line stream, so chunking by cell range with a yield every ~400 cells looks feasible, but needs a consistent snapshot (clone or journal); not profiled by function | +| load (parse_md) | deferrable | ~4k cells | pest parse + tree walk, linear | tree walk is chunkable by line; the pest parse of one text is not, unless the Data section is parsed line by line (grammar is line oriented); not profiled by function | +| persistence per command | deferrable (debounce) | ~700 cells | the whole-document `format_md` | coalesce/debounce or append-journal: formatting 2 s per command at target is unusable inline at any slicing | + +## What I could not measure + +* wasm: all numbers are native; the browser worker is typically slower. Real OPFS I/O, JSON *decode* and + DOM paint, the full `draw()` frame, and Safari are not covered. +* Records mode at 100k: SetCell, host and RSS (killed after open and keypress, ~76 s). +* Per-function profile: symbols are stripped in `[profile.release]`; attribution is reasoned from the + code and the timing exponents, not a flame graph. +* SetCell 100 ms/1 s for prune off past K=40 are extrapolations. +* Memory (`maxrss`) includes the generator's hash set and `recs` vector; it is an upper bound on the + document. Layout cost ~150 B/row per ADR 008, consistent with K=40 (64k rows, 235 MB peak with prune off + including the generator and a second layout during rebuild). +* Noise: boundary rows are 3 sequential processes, spread <=3% (e.g. K=17: 105.0-105.5 ms). Non-boundary + rows are single runs, some taken with 2-3 concurrent processes on a 16+ core machine; treat them as +-5-10%. diff --git a/context/test-evidence/tooling.md b/context/test-evidence/tooling.md index c7c92df3..305c172c 100644 --- a/context/test-evidence/tooling.md +++ b/context/test-evidence/tooling.md @@ -1772,3 +1772,146 @@ The output includes the user's inline answers on **Scope sweep.** `scripts/pr-watch` passes its JSON through `printf '%s' | jq`, never as an argument. `grep -rn 'argjson\|--arg '` over `scripts/`, `.agents/` and `.claude/skills/` finds no other data argument. + +### An agent authors its commits, and the committer is left alone — `scripts/agent-git-test.sh` (improvise-1xfb.3) + +**What was missing.** Every agent commit was authored as the repository owner, +so history could not say which agent wrote what. `scripts/agent-git` runs one +git command with `GIT_AUTHOR_NAME="Claude ($BEADS_ACTOR)"` and refuses, exit 2, +when `BEADS_ACTOR` is unset or empty. + +**Why not `git -c user.name=…`,** the mechanism the ticket and the brief both +offered first. `user.name` is the default for the committer as well as the +author, so it renames both. The ticket asks for the committer to be left +alone. Probed in a scratch repo: a `-c user.name` commit reads +`Claude (u) | Claude (u)` as author and committer. A `GIT_AUTHOR_NAME` +commit reads `Claude (t) | Owner`. The same probe showed that a merge +takes `GIT_AUTHOR_NAME` too, and that a plain `--amend` keeps the author it +amends. + +**A cherry-pick keeps its source's author** (Codex P2 and the review on PR +#310). The first version of the rule listed `cherry-pick` among the wrapper's +subcommands, but git ignores `GIT_AUTHOR_NAME` there. That is the behaviour +this repo wants, since rule 8 allows only `cherry-pick -x` of commits that keep +their own author. So the contract names only `commit` and `merge`. The case "a +cherry-pick keeps its source's author" picks a commit authored `Claude +(agent-b2)` with the wrapper set to `agent-a1`, and asserts `agent-b2`. A pick +that failed would leave the owner-authored base at HEAD and fail the case too. + +**Rivals** (run against the first eleven cases). +- **`exec git -c user.name="Claude ($BEADS_ACTOR)" "$@"`**: 10 passed, + 1 failed, "the committer is untouched" (got `Claude (agent-a1)`). +- **The refusal removed** (`if false`): 7 passed, 4 failed. They are "an unset + BEADS_ACTOR is refused" (exit 1 from `set -u`, not 2), "the refusal names + BEADS_ACTOR", "an empty BEADS_ACTOR is refused" (exit 0: the commit landed + under the name `Claude ()`), and "a refusal commits nothing". So `set -u` + alone would not cover the empty case. + +**Fixture.** The repo's identity comes from its own config, with +`GIT_CONFIG_GLOBAL=/dev/null` and `GIT_CONFIG_NOSYSTEM=1`, and every `GIT_*` +identity variable is unset first. So a caller's own identity cannot make the +author or committer assertions agree by accident. + +### The repo-stats pipeline computes what it claims to — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1) + +**What it pins.** The fixture repository has one commit before the window, a +side branch merged with `--no-ff`, a shrink in the next ISO week, and one file +in every area. So each number the report prints is known by construction. The +suite runs the real pipeline, code-maat and improvise included, and asserts +improvise's exported tables: each area's added, deleted and deletion ratio; +prose, code and the `ProseToCode` row; the week split; and the per-day totals. +It also checks the doc sizes and the coupling flags, and that an improvise that +refuses or warns makes the pipeline exit 3. + +**Two fixture choices are load-bearing.** +- The repo map's `4.` item sits after the `---` that closes the Gotchas + section, with no `##` heading between them. In an earlier fixture a `##` + heading followed the `---`, so the `---` rule could be deleted and the suite + stayed green. +- `churn.csv` must equal code-maat's `abs-churn` on every day. That compares + two independent derivations of one number, one of them improvise's + aggregation. The fixture has a binary-free, rename-free history, so the two + agree exactly. + +**Rivals**, each injected into the script or into `repo-stats.improvise` and run +through `checkers.sh repo-stats`: +- Rust src matched before Rust tests: the rust src and rust tests rows fail. +- `DeletionRatio = deleted / (added + deleted)`: six ratio rows fail. +- `ProseToCode = code / prose`: the `ProseToCode` row fails. +- `--no-merges` dropped: the commit count fails (6, wanted 5). +- The `---` end of the Gotchas section ignored: the gotcha count fails (4, wanted 3). +- A crate crossing as "the crates differ", so a crate against no crate counts: + the crate-crossing case fails. +- `kb.pl` counted as prose: the prose row and the ratio fail. +- The category padding removed: the import skipped every record, and both + pipeline runs failed with exit 3. That rival is retired: folder import + (improvise-lpzu) keeps sparse records, and the padding is gone. The suite + passes 41 of 41 without it. +- Months instead of ISO weeks: both week cases fail. +- A `warning:` from improvise ignored: the warning-stub case fails. + +### A window of empty commits, and four faults the first fixture missed — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1) + +**What broke.** ChatGPT Codex found it on PR #313, and the adversarial review +reproduced it. When every non-merge commit in the window is `--allow-empty`, +the log has commit headers and no files. `build_dir` took `churn.csv`'s header +from `rows[0]`, raised `IndexError` and exited 1. That exit is outside the +pipeline's failure split: it is neither a number nor improvise's exit 3. + +**Fix.** `churn.csv` has a fixed header. When no file changed, the script +skips code-maat and improvise, writes the header alone, and exits 0. + +**Red first.** The new case is a second fixture repository whose only +in-window commit is empty. Restoring the `rows[0]` header fails its run case +with the traceback. + +**The review's four surviving faults, each now killed:** +- **`--no-renames` dropped.** The fixture now renames `scripts/foo.sh` to + `scripts/bar.sh`. Without the flag git reports one `{foo.sh => bar.sh}` entity + with no lines changed, not a delete and an add. Four cases fail: the scripts/CI + row, the code row, the ratio row and the coupling count. +- **`crate_of` forgetting the root crate.** `src/main.rs` now co-changes with + both crates. The crossing list loses three pairs. +- **A failing `script` call ignored.** A third stub exits 0 for `import` and + refuses `script`. The pipeline then reads exports that were never written and + exits 1, not 3. +- **`.beads/` and `site/` left out of generated.** Both are in the fixture now. + The generated row and the other row both fail. + +**Also counted now:** `docs/guide.md` as other docs and `.codex/config.toml` as +agent docs. The coupling count is 16, not 15: `web/app.js` and +`scripts/foo.sh` change together in two commits, the 06-04 one and the rename. + +### The pipeline on folder import — `scripts/repo-stats-test.sh` (improvise-1xfb.5.1) + +Once #314 merged, `improvise import ` replaced the file list, the `-` +padding and the generated per-file views. The suite needed no change of +expectations and still passed 41 of 41. That shows only that the numbers +agree. It does **not** show that the model is right: the suite could not tell +sparse from padded. The delta review re-added `pad_categories` and the suite +still passed 41 of 41, while the model then had a `|-|` item in 13 places and +the `churn` view seated `agent-rules` on rows and paged everything else. The +view case counted 10 views either way. + +**What separates them now:** +- **"the sparse model has no padding item"**: no line of the model names `|-|`. +- **"the churn view seats …"**: four cases, one per seat. `date` is on rows, and + `agent-rules`, `coupled` and `crosses-crate`, which are coupling-only, are on + `none`. The churn view puts `entity` on columns, not `area` as the review + guessed. What discriminates is `date` on rows and the coupling-only + categories on `none`, which a padded CSV cannot give, because it claims all + nine categories. + +**Rivals:** +- **Padding re-added** (the review's own injection): 5 cases fail. They are + the `|-|` case, with 161 lines naming it in the fixture model, and all four + seat cases. +- **A report view named like a file stem**: `By day` renamed to `churn` leaves + 9 views, and the view-count case fails. +- **The foreign-CSV guard removed**: "a foreign CSV in --out is refused with + exit 2" fails with exit 0. The guard is the answer to the review's + non-blocking finding that `import .` picks up any CSV in `--out`. It refuses + instead of clearing. Deleting files in a directory the caller named is the + wrong default, and the script's own five CSVs are overwritten on every run, + so a rerun into the same `--out` is still accepted. The same fixture run + writes JSON and then text into one directory, which shows that. diff --git a/context/test-evidence/tui.md b/context/test-evidence/tui.md index 69cf6582..ffa6d66e 100644 --- a/context/test-evidence/tui.md +++ b/context/test-evidence/tui.md @@ -1429,3 +1429,51 @@ to the list's header row, which no item can cover. This test fills three rows with a three-line item and a second item after it. It asserts the first item is drawn whole and the header reads `1–1 of 2`. The rival **the header claims every item shown** fails it. + +### the_category_add_prompt_draws_as_the_item_add_prompt_does + +ADR 004 clause 8's one declared exception, decided by the user (Q1 on PR #122: +the green/yellow split between item-add and category-add is an accident). It +draws both prompts through the real `draw_bottom_bar` into a `TestBackend` and +compares the style of the prompt's first cell, after checking that cell really +holds the prompt's `n`. + +**Failed first**, at `gh/main` `b639266b` before any face existed: `left: +...yellow().bg(Indexed(235)).bold()`, `right: ...green()...` — the category-add +prompt was yellow. **Injected fault** after the migration: set the shipped +theme's `minibuffer.category-add` back to `Named(Yellow)`; this test fails and +nothing else in the TUI does, which is the point of naming the exception in a +test of its own rather than leaving it out of the no-op table. + +### the_shipped_theme_reproduces_every_migrated_literal + +ADR 004 clause 8's property: migrating a call site onto a face is a no-op. The +oracle is the literal tables as they stood before the migration — axis +accents, wizard field kinds, `mode_style`'s exhaustive match (kept verbatim as +`mode_bg_before_faces`) and the minibuffer's accent-on-`Indexed(235)`-bold — +and every one is compared with `face_style` of its face. Category-add is left +out on purpose (the entry above). It was first written against the old +functions themselves and passed, then the functions were deleted and their +tables moved into the test. + +**Injected faults**, each failing here by the case name in the message: +`axis.column` changed from Blue to Cyan (fails at `Column`); `face_style` made +to add `BOLD` for `Weight::Normal` instead of `Bold`; `Theme::resolve` folded so +that the parent wins over the child (fails at `Editing`, which draws DarkGray +instead of its own Green); and `Face::of_mode(TileSelect)` returning +`mode.resting` (also caught by `the_status_line_is_drawn_in_the_mode_face`). + +### the_two_front_ends_draw_the_same_nested_hierarchy_on_rows + +improvise-felo.6: Year > Quarter > Month in one category, under a grouped outer +category, with a year open two levels, a quarter closed and a year folded. The +precondition pins the header sequence, so the agreement cannot pass on a frame +that drew no nested header. What makes the two ends agree is that neither +derives membership: both walk the same `FrameEntry` list. Rival: the browser's +`render.rs` skipping a `Group` entry whose `depth > 0`. This test fails on the +row count (the TUI draws the quarter line, the browser does not); the column +test does not, since the band shows only each column's innermost group, which +the rival leaves alone. Group names are short because the TUI +truncates a band label to its column's width, which the browser does not: the +first version named its years `Y2024` and the column test failed on `▶ Y2…` +against `▶ Y2025`, a truncation, not a disagreement. diff --git a/crates/improvise-command/src/command/cmd/commit.rs b/crates/improvise-command/src/command/cmd/commit.rs index 802c72a1..7ed8d269 100644 --- a/crates/improvise-command/src/command/cmd/commit.rs +++ b/crates/improvise-command/src/command/cmd/commit.rs @@ -204,10 +204,15 @@ mod tests { let mut ctx = make_ctx(&m, &layout, ®); ctx.buffers = &bufs; let effects = CommitExport.execute(&ctx); - assert_eq!(effects.len(), 2); + // 3 effects: SetBuffer (clear), ChangeMode, ExportCsv (improvise-2mqz). + assert_eq!(effects.len(), 3); let dbg = effects_debug(&effects); assert!(dbg.contains("ExportCsv"), "Expected ExportCsv, got: {dbg}"); assert!(dbg.contains("Normal"), "Expected Normal mode, got: {dbg}"); + assert!( + dbg.contains("SetBuffer { name: \"export\", value: \"\" }"), + "Expected buffer clear, got: {dbg}" + ); assert!( dbg.find("Normal").unwrap() < dbg.find("ExportCsv").unwrap(), "the mode change must precede the write an abort can reach: {dbg}" @@ -1270,9 +1275,13 @@ impl Cmd for CommitSave { effect::change_mode(AppMode::Normal), ]; } - // The mode change comes **first**, or a failed `SaveAs` aborts with the - // minibuffer still drawn over its error (Copilot, PR #27). + // Clear before `SaveAs`, whose failure aborts; this command owns the clear + // because its LocalThenRequest cannot live in a sequence (improvise-2mqz). vec![ + Box::new(effect::SetBuffer { + name: "save".into(), + value: String::new(), + }), effect::change_mode(AppMode::Normal), Box::new(effect::SaveAs(std::path::PathBuf::from(path))), ] @@ -1294,9 +1303,13 @@ impl Cmd for CommitExport { effect::change_mode(AppMode::Normal), ]; } - // The mode change comes **first**, or a failed export aborts with the - // minibuffer still drawn over its error (gotcha 21). + // Clear before export, whose failure aborts; this command owns the clear + // because its LocalThenRequest cannot live in a sequence (improvise-2mqz). vec![ + Box::new(effect::SetBuffer { + name: "export".into(), + value: String::new(), + }), effect::change_mode(AppMode::Normal), Box::new(effect::ExportCsv(std::path::PathBuf::from(path))), ] diff --git a/crates/improvise-command/src/command/cmd/core.rs b/crates/improvise-command/src/command/cmd/core.rs index 7b9808e9..876e6b59 100644 --- a/crates/improvise-command/src/command/cmd/core.rs +++ b/crates/improvise-command/src/command/cmd/core.rs @@ -250,12 +250,37 @@ pub trait AtCursor: Cmd { fn cursor_mut(&mut self) -> &mut CursorState; } +/// How many arguments a command takes. The registry refuses any beyond it, for +/// every command in one place — `context/code/cli.md` §14. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MaxArgs { + AtMost(usize), + Any, +} + +impl MaxArgs { + fn admit(self, args: &[String]) -> Result<(), String> { + match self { + MaxArgs::AtMost(n) if args.len() > n => { + let extra: Vec = args[n..].iter().map(|a| format!("{a:?}")).collect(); + Err(format!( + "takes at most {n} argument(s), got {}; unexpected {}", + args.len(), + extra.join(" ") + )) + } + MaxArgs::AtMost(_) | MaxArgs::Any => Ok(()), + } + } +} + type BoxParseFn = Box Result, String>>; type BoxInteractiveFn = Box Result, String>>; /// A registered command entry with both text and interactive constructors. struct CmdEntry { name: &'static str, + max: MaxArgs, parse: BoxParseFn, interactive: BoxInteractiveFn, } @@ -295,10 +320,11 @@ impl CmdRegistry { pub fn register( &mut self, prototype: &C, + max: MaxArgs, parse: fn(&[String]) -> Result, fallback: Option>, ) { - self.push(prototype.name(), parse, fallback, |_, _| {}); + self.push(prototype.name(), max, parse, fallback, |_, _| {}); } /// As [`register`](Self::register), then the interactive arm moves the @@ -306,10 +332,11 @@ impl CmdRegistry { pub fn register_at_cursor( &mut self, prototype: &C, + max: MaxArgs, parse: fn(&[String]) -> Result, fallback: Option>, ) { - self.push(prototype.name(), parse, fallback, |cmd, ctx| { + self.push(prototype.name(), max, parse, fallback, |cmd, ctx| { *cmd.cursor_mut() = CursorState::from_ctx(ctx); }); } @@ -317,12 +344,14 @@ impl CmdRegistry { fn push( &mut self, name: &'static str, + max: MaxArgs, parse: fn(&[String]) -> Result, fallback: Option>, at_cursor: fn(&mut C, &CmdContext), ) { self.entries.push(CmdEntry { name, + max, parse: Box::new(move |args| parse(args).map(|c| Box::new(c) as Box)), interactive: Box::new(move |args, ctx| { let mut built = match (parse(args), fallback) { @@ -338,9 +367,10 @@ impl CmdRegistry { /// Register a command that needs no interactive context: BOTH arms are /// `parse`, so neither can demote the other — `context/code/command.md`. - pub fn register_pure(&mut self, prototype: &dyn Cmd, parse: ParseFn) { + pub fn register_pure(&mut self, prototype: &dyn Cmd, max: MaxArgs, parse: ParseFn) { self.entries.push(CmdEntry { name: prototype.name(), + max, parse: Box::new(parse), interactive: Box::new(move |args, _ctx| parse(args)), }); @@ -352,6 +382,7 @@ impl CmdRegistry { let name = f().name(); self.entries.push(CmdEntry { name, + max: MaxArgs::AtMost(0), parse: Box::new(move |_| Ok(f())), interactive: Box::new(move |_, _| Ok(f())), }); @@ -364,7 +395,9 @@ impl CmdRegistry { let canonical = self.resolve(name); for e in &self.entries { if e.name == canonical { - return (e.parse)(args).map_err(|err| format!("{name}: {err}")); + return (e.parse)(args) + .and_then(|cmd| e.max.admit(args).map(|()| cmd)) + .map_err(|err| format!("{name}: {err}")); } } Err(format!("Unknown command: {name}")) @@ -381,7 +414,7 @@ impl CmdRegistry { let name = self.resolve(name); for e in &self.entries { if e.name == name { - return (e.interactive)(args, ctx); + return (e.interactive)(args, ctx).and_then(|cmd| e.max.admit(args).map(|()| cmd)); } } Err(format!("Unknown command: {name}")) @@ -401,16 +434,31 @@ pub(super) fn require_args(args: &[String], n: usize) -> Result<(), String> { } } -/// Parse Cat/Item coordinate args into a CellKey. -pub(super) fn parse_cell_key_from_args(args: &[String]) -> crate::model::cell::CellKey { - let coords: Vec<(String, String)> = args +/// Parse Cat/Item coordinate args into a CellKey, refusing any token that is not +/// one rather than dropping it — `context/code/cli.md` §14. +pub(super) fn parse_cell_key_from_args( + args: &[String], +) -> Result { + let coords = args .iter() - .filter_map(|s| { - let (cat, item) = s.split_once('/')?; - Some((cat.to_string(), item.to_string())) + .map(|s| match s.split_once('/') { + Some((cat, item)) if !cat.is_empty() && !item.is_empty() => { + Ok((cat.to_string(), item.to_string())) + } + _ => Err(format!("{s:?} is not a Category/Item coordinate")), }) - .collect(); - crate::model::cell::CellKey::new(coords) + .collect::, _>>()?; + Ok(crate::model::cell::CellKey::new(coords)) +} + +/// A cursor fallback answers only for coordinates that were OMITTED; given and +/// wrong, they are refused, never the cursor's cell — `context/code/cli.md` §14. +pub(super) fn only_when_omitted(args: &[String]) -> Result<(), String> { + if args.is_empty() { + return Ok(()); + } + parse_cell_key_from_args(args)?; + Err("takes Category/Item coordinates, or none to use the cursor".to_string()) } /// Read the current value of a named buffer from context. diff --git a/crates/improvise-command/src/command/cmd/effect_cmds.rs b/crates/improvise-command/src/command/cmd/effect_cmds.rs index 44d93e32..4ed3ffa4 100644 --- a/crates/improvise-command/src/command/cmd/effect_cmds.rs +++ b/crates/improvise-command/src/command/cmd/effect_cmds.rs @@ -1,13 +1,13 @@ use crate::model::cell::CellValue; use crate::ui::effect::{self, Effect, ViewTarget}; -use crate::view::Axis; -use super::core::{Cmd, CmdContext, require_args}; +use super::core::{Cmd, CmdContext, MaxArgs, parse_axis, parse_cell_key_from_args, require_args}; #[cfg(test)] mod tests { use super::*; use crate::command::cmd::test_helpers::*; + use crate::view::Axis; #[test] fn add_category_cmd_produces_add_category_effect() { @@ -59,7 +59,7 @@ mod tests { let ctx = make_ctx(&m, &layout, ®); let cmd = SetAxisCmd(vec!["Type".to_string(), "col".to_string()]); let effects = cmd.execute(&ctx); - assert_eq!(effects.len(), 1); + assert_eq!(effects.len(), 2, "the name check, then the write"); let mut app = crate::ui::app::App::new(two_cat_model(), None); app.apply_effects(effects); let view = app.model_state.workbook.active_view(); @@ -95,6 +95,104 @@ mod tests { assert!(present.model_state.workbook.model.formulas().is_empty()); } + /// Run `name args` built by the pure arm against a fresh `App` on `wb`. + fn run_on(wb: &crate::workbook::Workbook, name: &str, args: &[&str]) -> crate::ui::app::App { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let mut app = crate::ui::app::App::new(wb.clone(), None); + let cmd = crate::command::cmd::registry::default_registry() + .parse(name, &args) + .unwrap_or_else(|e| panic!("`{name} {args:?}` must build: {e}")); + let effects = { + let ctx = app.cmd_context( + crate::command::key::KeyCode::Null, + crate::command::key::KeyModifiers::NONE, + ); + cmd.execute(&ctx) + }; + app.apply_effects(effects); + app + } + + /// A document where every sibling below has something real to name. + fn named_things() -> crate::workbook::Workbook { + let mut wb = two_cat_model(); + wb.add_item_in_group("Type", "Tools", "Hardware").unwrap(); + wb.create_view("Second"); + wb + } + + /// improvise-31oe.2 and its siblings: each names something missing, is + /// refused naming it, and changes nothing. Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_command_naming_something_the_document_lacks_is_a_refusal() { + let wb = named_things(); + let cases: [(&str, &[&str], &str); 10] = [ + ("set-axis", &["Regoin", "column"], "Regoin"), + ("set-page", &["Regoin", "Food"], "Regoin"), + ("set-page", &["Type", "Nowhere"], "Nowhere"), + ("hide-item", &["Regoin", "Food"], "Regoin"), + ("hide-item", &["Type", "Nowhere"], "Nowhere"), + ("show-item", &["Regoin", "Food"], "Regoin"), + ("show-item", &["Type", "Nowhere"], "Nowhere"), + ("toggle-group", &["Regoin", "Hardware"], "Regoin"), + ("toggle-group", &["Type", "Nowhere"], "Nowhere"), + ("switch-view", &["Nowhere"], "Nowhere"), + ]; + let untouched = crate::persistence::format_md(&wb); + for (name, args, missing) in cases { + let app = run_on(&wb, name, args); + let said = &app.view_state.status_msg; + assert!( + app.last_batch_refused(), + "`{name} {args:?}` was not refused: {said:?}" + ); + assert!( + said.contains(missing), + "`{name} {args:?}` did not name {missing}: {said:?}" + ); + assert_eq!( + crate::persistence::format_md(&app.model_state.workbook), + untouched, + "`{name} {args:?}` changed the document" + ); + assert_eq!( + app.view_state.active_view, wb.active_view, + "`{name}` moved the view" + ); + } + } + + /// The controls: the same commands naming what IS there are not refused. + #[test] + fn a_command_naming_what_the_document_has_is_not_refused() { + let wb = named_things(); + let cases: [(&str, &[&str]); 6] = [ + ("set-axis", &["Type", "column"]), + ("set-page", &["Type", "Clothing"]), + ("hide-item", &["Type", "Clothing"]), + ("show-item", &["Type", "Clothing"]), + ("toggle-group", &["Type", "Hardware"]), + ("switch-view", &["Second"]), + ]; + for (name, args) in cases { + let app = run_on(&wb, name, args); + assert!( + !app.last_batch_refused(), + "`{name} {args:?}` was refused: {:?}", + app.view_state.status_msg + ); + } + } + + /// An axis keyword is an argument error, caught before anything runs. + #[test] + fn set_axis_with_an_unknown_axis_is_an_argument_error() { + let err = crate::command::parse_line("set-axis Type sideways") + .expect_err("sideways is not an axis"); + assert!(err.starts_with("set-axis:"), "names the command: {err}"); + assert!(err.contains("sideways"), "names the axis: {err}"); + } + #[test] fn write_cmd_without_args_saves() { let m = two_cat_model(); @@ -129,7 +227,7 @@ mod tests { // Thin Cmd wrappers around effects, on the same path as keymap dispatch. macro_rules! effect_cmd { - ($name:ident, $cmd_name:expr, $parse:expr, $exec:expr) => { + ($name:ident, $cmd_name:expr, $max:expr, $parse:expr, $exec:expr) => { #[derive(Debug)] pub struct $name(pub Vec); impl Cmd for $name { @@ -143,6 +241,7 @@ macro_rules! effect_cmd { } } impl $name { + pub const MAX_ARGS: MaxArgs = $max; pub fn parse(args: &[String]) -> Result, String> { #[allow(clippy::redundant_closure_call)] ($parse)(args)?; @@ -152,9 +251,16 @@ macro_rules! effect_cmd { }; } +/// `effect`, preceded by the check that what it names exists, run at APPLY time: +/// a `:` line is built before any of it runs — `context/code/cli.md` §15. +fn unless_unknown(needs: effect::Needs, effect: impl Effect + 'static) -> Vec> { + vec![Box::new(effect::RequireKnown(needs)), Box::new(effect)] +} + effect_cmd!( AddCategoryCmd, "add-category", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::AddCategory(args[0].clone()))] @@ -164,6 +270,7 @@ effect_cmd!( effect_cmd!( AddItemCmd, "add-item", + MaxArgs::AtMost(2), |args: &[String]| require_args(args, 2), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::AddItem { @@ -176,6 +283,7 @@ effect_cmd!( effect_cmd!( AddItemsCmd, "add-items", + MaxArgs::Any, |args: &[String]| { if args.len() < 2 { Err("add-items requires a category and at least one item".to_string()) @@ -200,6 +308,7 @@ effect_cmd!( effect_cmd!( AddItemInGroupCmd, "add-item-in-group", + MaxArgs::AtMost(3), |args: &[String]| require_args(args, 3), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::AddItemInGroup { @@ -213,11 +322,12 @@ effect_cmd!( effect_cmd!( SetCellCmd, "set-cell", + MaxArgs::Any, |args: &[String]| { if args.len() < 2 { Err("set-cell requires a value and at least one Cat/Item coordinate".to_string()) } else { - Ok(()) + parse_cell_key_from_args(&args[1..]).map(|_| ()) } }, |args: &Vec, _ctx: &CmdContext| -> Vec> { @@ -226,21 +336,17 @@ effect_cmd!( } else { CellValue::Text(args[0].clone()) }; - let coords: Vec<(String, String)> = args[1..] - .iter() - .filter_map(|s| { - let (cat, item) = s.split_once('/')?; - Some((cat.to_string(), item.to_string())) - }) - .collect(); - let key = crate::model::cell::CellKey::new(coords); - vec![Box::new(effect::SetCell(key, value))] + match parse_cell_key_from_args(&args[1..]) { + Ok(key) => vec![Box::new(effect::SetCell(key, value))], + Err(why) => vec![effect::refuse(why)], + } } ); effect_cmd!( AddFormulaCmd, "add-formula", + MaxArgs::AtMost(2), |args: &[String]| { if args.is_empty() || args.len() > 2 { // The arity alone does not tell anyone to quote — improvise-z05h. @@ -269,6 +375,7 @@ effect_cmd!( effect_cmd!( ClearBufferCmd, "clear-buffer", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![super::core::write_buffer(&args[0], String::new())] @@ -278,6 +385,7 @@ effect_cmd!( effect_cmd!( RemoveFormulaCmd, "remove-formula", + MaxArgs::AtMost(2), |args: &[String]| require_args(args, 2), |args: &Vec, ctx: &CmdContext| -> Vec> { let (category, target) = (&args[0], &args[1]); @@ -298,6 +406,7 @@ effect_cmd!( effect_cmd!( CreateViewCmd, "create-view", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::CreateView(args[0].clone()))] @@ -307,6 +416,7 @@ effect_cmd!( effect_cmd!( DeleteViewCmd, "delete-view", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::DeleteView(args[0].clone()))] @@ -316,87 +426,111 @@ effect_cmd!( effect_cmd!( SwitchViewCmd, "switch-view", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::SwitchView(args[0].clone()))] + unless_unknown( + effect::Needs::View(args[0].clone()), + effect::SwitchView(args[0].clone()), + ) } ); effect_cmd!( SetAxisCmd, "set-axis", - |args: &[String]| require_args(args, 2), + MaxArgs::AtMost(2), + |args: &[String]| { + require_args(args, 2)?; + parse_axis(&args[1]).map(|_| ()) + }, |args: &Vec, _ctx: &CmdContext| -> Vec> { - let axis = match args[1].to_lowercase().as_str() { - "row" => Axis::Row, - "column" | "col" => Axis::Column, - "page" => Axis::Page, - "none" => Axis::None, - _ => return vec![], // parse step already validated - }; - vec![Box::new(effect::SetAxis { - view: ViewTarget::Active, - category: args[0].clone(), - axis, - })] + match parse_axis(&args[1]) { + Ok(axis) => unless_unknown( + effect::Needs::Category(args[0].clone()), + effect::SetAxis { + view: ViewTarget::Active, + category: args[0].clone(), + axis, + }, + ), + Err(why) => vec![effect::refuse(why)], + } } ); effect_cmd!( SetPageCmd, "set-page", + MaxArgs::AtMost(2), |args: &[String]| require_args(args, 2), |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::SetPageSelection { - view: ViewTarget::Active, - category: args[0].clone(), - item: args[1].clone(), - })] + unless_unknown( + effect::Needs::Item(args[0].clone(), args[1].clone()), + effect::SetPageSelection { + view: ViewTarget::Active, + category: args[0].clone(), + item: args[1].clone(), + }, + ) } ); effect_cmd!( ToggleGroupCmd, "toggle-group", + MaxArgs::AtMost(2), |args: &[String]| require_args(args, 2), |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::ToggleGroup { - view: ViewTarget::Active, - category: args[0].clone(), - group: args[1].clone(), - })] + unless_unknown( + effect::Needs::Group(args[0].clone(), args[1].clone()), + effect::ToggleGroup { + view: ViewTarget::Active, + category: args[0].clone(), + group: args[1].clone(), + }, + ) } ); effect_cmd!( HideItemCmd, "hide-item", + MaxArgs::AtMost(2), |args: &[String]| require_args(args, 2), |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::HideItem { - view: ViewTarget::Active, - category: args[0].clone(), - item: args[1].clone(), - })] + unless_unknown( + effect::Needs::Item(args[0].clone(), args[1].clone()), + effect::HideItem { + view: ViewTarget::Active, + category: args[0].clone(), + item: args[1].clone(), + }, + ) } ); effect_cmd!( ShowItemCmd, "show-item", + MaxArgs::AtMost(2), |args: &[String]| require_args(args, 2), |args: &Vec, _ctx: &CmdContext| -> Vec> { - vec![Box::new(effect::ShowItem { - view: ViewTarget::Active, - category: args[0].clone(), - item: args[1].clone(), - })] + unless_unknown( + effect::Needs::Item(args[0].clone(), args[1].clone()), + effect::ShowItem { + view: ViewTarget::Active, + category: args[0].clone(), + item: args[1].clone(), + }, + ) } ); effect_cmd!( SaveAsCmd, "save-as", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::SaveAs(std::path::PathBuf::from(&args[0])))] @@ -406,6 +540,7 @@ effect_cmd!( effect_cmd!( SetFormatCmd, "set-format", + MaxArgs::Any, |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::SetNumberFormat( @@ -418,6 +553,7 @@ effect_cmd!( effect_cmd!( ImportCmd, "import", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::StartImportWizard(args[0].clone()))] @@ -427,6 +563,7 @@ effect_cmd!( effect_cmd!( ExportCmd, "export", + MaxArgs::AtMost(1), |_args: &[String]| -> Result<(), String> { Ok(()) }, |args: &Vec, _ctx: &CmdContext| -> Vec> { let path = args.first().map(|s| s.as_str()).unwrap_or("export.csv"); @@ -437,6 +574,7 @@ effect_cmd!( effect_cmd!( WriteCmd, "w", + MaxArgs::AtMost(1), |_args: &[String]| -> Result<(), String> { Ok(()) }, |args: &Vec, _ctx: &CmdContext| -> Vec> { if args.is_empty() { @@ -450,6 +588,7 @@ effect_cmd!( effect_cmd!( HelpCmd, "help", + MaxArgs::AtMost(0), |_args: &[String]| -> Result<(), String> { Ok(()) }, |_args: &Vec, _ctx: &CmdContext| -> Vec> { vec![ @@ -462,6 +601,7 @@ effect_cmd!( effect_cmd!( HelpPageNextCmd, "help-page-next", + MaxArgs::AtMost(0), |_args: &[String]| -> Result<(), String> { Ok(()) }, |_args: &Vec, _ctx: &CmdContext| -> Vec> { vec![effect::help_page_next()] @@ -471,6 +611,7 @@ effect_cmd!( effect_cmd!( HelpPagePrevCmd, "help-page-prev", + MaxArgs::AtMost(0), |_args: &[String]| -> Result<(), String> { Ok(()) }, |_args: &Vec, _ctx: &CmdContext| -> Vec> { vec![effect::help_page_prev()] @@ -480,6 +621,7 @@ effect_cmd!( effect_cmd!( LoadModelCmd, "load", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::LoadModel(std::path::PathBuf::from( @@ -491,6 +633,7 @@ effect_cmd!( effect_cmd!( ExportCsvCmd, "export-csv", + MaxArgs::AtMost(1), |args: &[String]| require_args(args, 1), |args: &Vec, _ctx: &CmdContext| -> Vec> { vec![Box::new(effect::ExportCsv(std::path::PathBuf::from( @@ -502,6 +645,7 @@ effect_cmd!( effect_cmd!( ImportJsonCmd, "import-json", + MaxArgs::AtMost(3), |args: &[String]| { if args.is_empty() { Err("import-json requires a path".to_string()) diff --git a/crates/improvise-command/src/command/cmd/fit.rs b/crates/improvise-command/src/command/cmd/fit.rs index da7fb3c3..8c2fb087 100644 --- a/crates/improvise-command/src/command/cmd/fit.rs +++ b/crates/improvise-command/src/command/cmd/fit.rs @@ -4,7 +4,7 @@ use improvise_gp::fit::one_line; use improvise_gp::{FitRequest, fit_measure, fitted_measure_name}; -use crate::command::cmd::core::{Cmd, CmdContext}; +use crate::command::cmd::core::{Cmd, CmdContext, MaxArgs}; use crate::ui::effect::{self, Effect}; /// A seed as the status line spells it (`0x…`, either case) or in decimal. The hex @@ -27,6 +27,8 @@ pub struct FitMeasure { } impl FitMeasure { + pub const MAX_ARGS: MaxArgs = MaxArgs::AtMost(3); + /// A nameless instance for `register_pure`, which reads a command's name /// off a prototype rather than off a string literal (gotcha 15). pub fn prototype() -> Self { diff --git a/crates/improvise-command/src/command/cmd/grid.rs b/crates/improvise-command/src/command/cmd/grid.rs index 03506c3b..67abeb9e 100644 --- a/crates/improvise-command/src/command/cmd/grid.rs +++ b/crates/improvise-command/src/command/cmd/grid.rs @@ -177,6 +177,7 @@ mod tests { wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "May", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb } @@ -213,7 +214,7 @@ mod tests { !app.model_state .workbook .active_view() - .is_group_collapsed("Month", "Q2"), + .is_group_collapsed("Month", &["Q2"]), "the fixture starts expanded, or the assertion below proves nothing" ); @@ -225,11 +226,11 @@ mod tests { ); let view = app.model_state.workbook.active_view(); assert!( - view.is_group_collapsed("Month", "Q2"), + view.is_group_collapsed("Month", &["Q2"]), "the effect was returned but the view did not move" ); assert!( - !view.is_group_collapsed("Month", "Q1"), + !view.is_group_collapsed("Month", &["Q1"]), "the neighbour was left alone" ); } @@ -262,9 +263,9 @@ mod tests { ); app.apply_effects(effects); let view = app.model_state.workbook.active_view(); - assert!(view.is_group_collapsed("Month", "Q2")); + assert!(view.is_group_collapsed("Month", &["Q2"])); assert!( - !view.is_group_collapsed("Month", "Q1"), + !view.is_group_collapsed("Month", &["Q1"]), "the neighbour stays" ); } @@ -286,6 +287,7 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.add_item("Type", "Tools").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Column); @@ -328,6 +330,66 @@ mod tests { ); } + /// improvise-felo.6: `z` walks DOWN a closed hierarchy — year, then quarter — + /// and back up, the cursor on the group it acted on each time. + #[test] + fn z_opens_a_closed_hierarchy_one_level_at_a_time_and_closes_it_again() { + use crate::ui::app::App; + let mut wb = crate::workbook::Workbook::new("T"); + wb.add_category("Date").unwrap(); + wb.add_category("Type").unwrap(); + for (month, quarter) in [ + ("Jan", "Q1"), + ("Feb", "Q1"), + ("Apr", "Q2"), + ("Jan25", "Q1-25"), + ] { + wb.add_item_in_group("Date", month, quarter).unwrap(); + } + let date = wb.model.category_mut("Date").unwrap(); + for (quarter, year) in [("Q1", "Y2024"), ("Q2", "Y2024"), ("Q1-25", "Y2025")] { + date.add_group(crate::model::category::Group::new(quarter).with_parent(year)); + } + wb.add_item("Type", "Food").unwrap(); + let mut app = App::new(wb, None); + app.rebuild_layout(); + assert_eq!(row_labels(&app), ["Y2024", "Y2025"], "closed by default"); + + app.cursor_mut().selected = (1, 0); + toggle_group_at_cursor(&mut app); + assert_eq!( + row_labels(&app), + ["Y2024", "Q1-25"], + "Y2025 opens to its quarter" + ); + assert_eq!(app.cursor().selected, (1, 0)); + + toggle_group_at_cursor(&mut app); + assert_eq!( + row_labels(&app), + ["Y2024", "Jan25"], + "then the quarter to its month" + ); + + toggle_group_at_cursor(&mut app); + assert_eq!( + row_labels(&app), + ["Y2024", "Q1-25"], + "z on the month closes its quarter" + ); + app.cursor_mut().selected = (0, 0); + toggle_group_at_cursor(&mut app); + assert_eq!(row_labels(&app), ["Q1", "Q2", "Q1-25"]); + assert_eq!( + app.model_state + .workbook + .active_view() + .group_fold("Date", "Q1-25"), + None, + "closing back to the default leaves no fold behind" + ); + } + /// Evidence: `context/test-evidence/command.md`. #[test] fn toggling_a_group_collapses_that_group_and_the_cursor_follows_it() { @@ -393,6 +455,7 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("Month", Axis::Row); @@ -704,7 +767,9 @@ impl ToggleGroupAtCursor { }; let prefix = tuple.get(..cats.iter().position(|c| c == cat)?)?; let mut view = ctx.view.clone(); - view.toggle_group_collapse(cat, group); + let chain = ctx.model.group_chain(cat, group); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); + view.toggle_group_collapse(cat, &chain); let after = GridLayout::with_frozen_records(ctx.model, &view, None); match self.is_row { true => after.row_run_start(cat, group, prefix), diff --git a/crates/improvise-command/src/command/cmd/mod.rs b/crates/improvise-command/src/command/cmd/mod.rs index cf82f2ff..fc4523c7 100644 --- a/crates/improvise-command/src/command/cmd/mod.rs +++ b/crates/improvise-command/src/command/cmd/mod.rs @@ -155,6 +155,7 @@ pub(super) mod test_helpers { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for (i, month) in ["Jan", "Feb", "Apr", "May"].into_iter().enumerate() { @@ -168,7 +169,7 @@ pub(super) mod test_helpers { } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = crate::ui::app::App::new(wb, None); app.rebuild_layout(); app.cursor_mut().selected = (2, 1); diff --git a/crates/improvise-command/src/command/cmd/mode.rs b/crates/improvise-command/src/command/cmd/mode.rs index b72d49eb..dfdf28a2 100644 --- a/crates/improvise-command/src/command/cmd/mode.rs +++ b/crates/improvise-command/src/command/cmd/mode.rs @@ -197,8 +197,9 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let layout = make_layout(&wb); let reg = make_registry(); let mut ctx = make_ctx(&wb, &layout, ®); diff --git a/crates/improvise-command/src/command/cmd/registry.rs b/crates/improvise-command/src/command/cmd/registry.rs index e394c118..e7bb964c 100644 --- a/crates/improvise-command/src/command/cmd/registry.rs +++ b/crates/improvise-command/src/command/cmd/registry.rs @@ -81,52 +81,130 @@ pub fn default_registry() -> CmdRegistry { let mut r = CmdRegistry::new(); // ── Model mutations (effect_cmd! wrappers) ─────────────────────────── - r.register_pure(&AddCategoryCmd(vec![]), AddCategoryCmd::parse); - r.register_pure(&AddItemCmd(vec![]), AddItemCmd::parse); - r.register_pure(&AddItemsCmd(vec![]), AddItemsCmd::parse); - r.register_pure(&AddItemInGroupCmd(vec![]), AddItemInGroupCmd::parse); - r.register_pure(&SetCellCmd(vec![]), SetCellCmd::parse); - r.register_pure(&ClearBufferCmd(vec![]), ClearBufferCmd::parse); + r.register_pure( + &AddCategoryCmd(vec![]), + AddCategoryCmd::MAX_ARGS, + AddCategoryCmd::parse, + ); + r.register_pure(&AddItemCmd(vec![]), AddItemCmd::MAX_ARGS, AddItemCmd::parse); + r.register_pure( + &AddItemsCmd(vec![]), + AddItemsCmd::MAX_ARGS, + AddItemsCmd::parse, + ); + r.register_pure( + &AddItemInGroupCmd(vec![]), + AddItemInGroupCmd::MAX_ARGS, + AddItemInGroupCmd::parse, + ); + r.register_pure(&SetCellCmd(vec![]), SetCellCmd::MAX_ARGS, SetCellCmd::parse); + r.register_pure( + &ClearBufferCmd(vec![]), + ClearBufferCmd::MAX_ARGS, + ClearBufferCmd::parse, + ); r.register( &ClearCellCommand { key: CellKey::new(vec![]), }, + MaxArgs::Any, |args| { if args.is_empty() { return Err("clear-cell requires at least one Cat/Item coordinate".into()); } Ok(ClearCellCommand { - key: parse_cell_key_from_args(args), + key: parse_cell_key_from_args(args)?, }) }, - Some(|_, ctx| { + Some(|args, ctx| { + only_when_omitted(args)?; Ok(ClearCellCommand { key: cursor_value_key(ctx)?, }) }), ); - r.register_pure(&AddFormulaCmd(vec![]), AddFormulaCmd::parse); - r.register_pure(&FitMeasure::prototype(), FitMeasure::parse); - r.register_pure(&RemoveFormulaCmd(vec![]), RemoveFormulaCmd::parse); - r.register_pure(&CreateViewCmd(vec![]), CreateViewCmd::parse); - r.register_pure(&DeleteViewCmd(vec![]), DeleteViewCmd::parse); - r.register_pure(&SwitchViewCmd(vec![]), SwitchViewCmd::parse); - r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::parse); - r.register_pure(&SetPageCmd(vec![]), SetPageCmd::parse); - r.register_pure(&ToggleGroupCmd(vec![]), ToggleGroupCmd::parse); - r.register_pure(&HideItemCmd(vec![]), HideItemCmd::parse); - r.register_pure(&ShowItemCmd(vec![]), ShowItemCmd::parse); - r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::parse); - r.register_pure(&LoadModelCmd(vec![]), LoadModelCmd::parse); - r.register_pure(&ExportCsvCmd(vec![]), ExportCsvCmd::parse); - r.register_pure(&ImportJsonCmd(vec![]), ImportJsonCmd::parse); - r.register_pure(&SetFormatCmd(vec![]), SetFormatCmd::parse); - r.register_pure(&ImportCmd(vec![]), ImportCmd::parse); - r.register_pure(&ExportCmd(vec![]), ExportCmd::parse); - r.register_pure(&WriteCmd(vec![]), WriteCmd::parse); - r.register_pure(&HelpCmd(vec![]), HelpCmd::parse); - r.register_pure(&HelpPageNextCmd(vec![]), HelpPageNextCmd::parse); - r.register_pure(&HelpPagePrevCmd(vec![]), HelpPagePrevCmd::parse); + r.register_pure( + &AddFormulaCmd(vec![]), + AddFormulaCmd::MAX_ARGS, + AddFormulaCmd::parse, + ); + r.register_pure( + &FitMeasure::prototype(), + FitMeasure::MAX_ARGS, + FitMeasure::parse, + ); + r.register_pure( + &RemoveFormulaCmd(vec![]), + RemoveFormulaCmd::MAX_ARGS, + RemoveFormulaCmd::parse, + ); + r.register_pure( + &CreateViewCmd(vec![]), + CreateViewCmd::MAX_ARGS, + CreateViewCmd::parse, + ); + r.register_pure( + &DeleteViewCmd(vec![]), + DeleteViewCmd::MAX_ARGS, + DeleteViewCmd::parse, + ); + r.register_pure( + &SwitchViewCmd(vec![]), + SwitchViewCmd::MAX_ARGS, + SwitchViewCmd::parse, + ); + r.register_pure(&SetAxisCmd(vec![]), SetAxisCmd::MAX_ARGS, SetAxisCmd::parse); + r.register_pure(&SetPageCmd(vec![]), SetPageCmd::MAX_ARGS, SetPageCmd::parse); + r.register_pure( + &ToggleGroupCmd(vec![]), + ToggleGroupCmd::MAX_ARGS, + ToggleGroupCmd::parse, + ); + r.register_pure( + &HideItemCmd(vec![]), + HideItemCmd::MAX_ARGS, + HideItemCmd::parse, + ); + r.register_pure( + &ShowItemCmd(vec![]), + ShowItemCmd::MAX_ARGS, + ShowItemCmd::parse, + ); + r.register_pure(&SaveAsCmd(vec![]), SaveAsCmd::MAX_ARGS, SaveAsCmd::parse); + r.register_pure( + &LoadModelCmd(vec![]), + LoadModelCmd::MAX_ARGS, + LoadModelCmd::parse, + ); + r.register_pure( + &ExportCsvCmd(vec![]), + ExportCsvCmd::MAX_ARGS, + ExportCsvCmd::parse, + ); + r.register_pure( + &ImportJsonCmd(vec![]), + ImportJsonCmd::MAX_ARGS, + ImportJsonCmd::parse, + ); + r.register_pure( + &SetFormatCmd(vec![]), + SetFormatCmd::MAX_ARGS, + SetFormatCmd::parse, + ); + r.register_pure(&ImportCmd(vec![]), ImportCmd::MAX_ARGS, ImportCmd::parse); + r.register_pure(&ExportCmd(vec![]), ExportCmd::MAX_ARGS, ExportCmd::parse); + r.register_pure(&WriteCmd(vec![]), WriteCmd::MAX_ARGS, WriteCmd::parse); + r.register_pure(&HelpCmd(vec![]), HelpCmd::MAX_ARGS, HelpCmd::parse); + r.register_pure( + &HelpPageNextCmd(vec![]), + HelpPageNextCmd::MAX_ARGS, + HelpPageNextCmd::parse, + ); + r.register_pure( + &HelpPagePrevCmd(vec![]), + HelpPagePrevCmd::MAX_ARGS, + HelpPagePrevCmd::parse, + ); // ── Navigation (unified Move) ────────────────────────────────────── r.register_at_cursor( @@ -135,6 +213,7 @@ pub fn default_registry() -> CmdRegistry { cursor: CursorState::default(), cmd_name: "move-selection", }, + MaxArgs::AtMost(2), |args| { require_args(args, 2)?; let dr = args[0].parse::().map_err(|e| e.to_string())?; @@ -160,6 +239,7 @@ pub fn default_registry() -> CmdRegistry { cursor: CursorState::default(), cmd_name: $name, }, + MaxArgs::AtMost(0), |_| { Ok(Move { kind: if $to_end { @@ -185,6 +265,7 @@ pub fn default_registry() -> CmdRegistry { cursor: CursorState::default(), cmd_name: "scroll-rows", }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let n = args[0].parse::().map_err(|e| e.to_string())?; @@ -202,6 +283,7 @@ pub fn default_registry() -> CmdRegistry { cursor: CursorState::default(), cmd_name: "page-scroll", }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let dir = args[0].parse::().map_err(|e| e.to_string())?; @@ -217,6 +299,7 @@ pub fn default_registry() -> CmdRegistry { &EnterAdvance { cursor: CursorState::default(), }, + MaxArgs::AtMost(0), |_| { Ok(EnterAdvance { cursor: CursorState { @@ -239,15 +322,17 @@ pub fn default_registry() -> CmdRegistry { &YankCell { key: CellKey::new(vec![]), }, + MaxArgs::Any, |args| { if args.is_empty() { return Err("yank requires at least one Cat/Item coordinate".into()); } Ok(YankCell { - key: parse_cell_key_from_args(args), + key: parse_cell_key_from_args(args)?, }) }, - Some(|_, ctx| { + Some(|args, ctx| { + only_when_omitted(args)?; Ok(YankCell { key: cursor_key_to_read(ctx)?, }) @@ -257,15 +342,17 @@ pub fn default_registry() -> CmdRegistry { &PasteCell { at: PasteAt::Spelled(CellKey::new(vec![])), }, + MaxArgs::Any, |args| { if args.is_empty() { return Err("paste requires at least one Cat/Item coordinate".into()); } Ok(PasteCell { - at: PasteAt::Spelled(parse_cell_key_from_args(args)), + at: PasteAt::Spelled(parse_cell_key_from_args(args)?), }) }, - Some(|_, ctx| { + Some(|args, ctx| { + only_when_omitted(args)?; Ok(PasteCell { at: PasteAt::Resolved(cursor_value_key(ctx)?), }) @@ -288,6 +375,7 @@ pub fn default_registry() -> CmdRegistry { &EditOrDrill { edit_mode: AppMode::Normal, }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let edit_mode = parse_mode_name(&args[0])?; @@ -298,6 +386,7 @@ pub fn default_registry() -> CmdRegistry { &EnterEditAtCursorCmd { target_mode: AppMode::Normal, }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let target_mode = parse_mode_name(&args[0])?; @@ -309,15 +398,17 @@ pub fn default_registry() -> CmdRegistry { &DrillIntoCell { key: crate::model::cell::CellKey::new(vec![]), }, + MaxArgs::Any, |args| { if args.is_empty() { return Err("drill-into-cell requires Cat/Item coordinates".into()); } Ok(DrillIntoCell { - key: parse_cell_key_from_args(args), + key: parse_cell_key_from_args(args)?, }) }, - Some(|_, ctx| { + Some(|args, ctx| { + only_when_omitted(args)?; Ok(DrillIntoCell { key: cursor_key(ctx)?, }) @@ -325,13 +416,13 @@ pub fn default_registry() -> CmdRegistry { ); r.register_nullary(|| Box::new(ViewNavigate { forward: false })); r.register_nullary(|| Box::new(ViewNavigate { forward: true })); - r.register_pure(&EnterMode(AppMode::Normal), |args| { + r.register_pure(&EnterMode(AppMode::Normal), MaxArgs::AtMost(1), |args| { require_args(args, 1)?; Ok(Box::new(EnterMode(parse_mode_name(&args[0])?))) }); // ── Search ─────────────────────────────────────────────────────────── - r.register_pure(&SearchNavigate(true), |args| { + r.register_pure(&SearchNavigate(true), MaxArgs::AtMost(1), |args| { let forward = args.first().map(|s| s != "backward").unwrap_or(true); Ok(Box::new(SearchNavigate(forward))) }); @@ -344,6 +435,7 @@ pub fn default_registry() -> CmdRegistry { panel: Panel::Formula, target: None, }, + MaxArgs::AtMost(3), |args| { // Parse: toggle-panel-and-focus [open] [focused] require_args(args, 1)?; @@ -360,6 +452,7 @@ pub fn default_registry() -> CmdRegistry { &TogglePanelVisibility { panel: Panel::Formula, }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let panel = parse_panel(&args[0])?; @@ -373,6 +466,7 @@ pub fn default_registry() -> CmdRegistry { panel: Panel::Formula, delta: 0, }, + MaxArgs::AtMost(2), |args| { require_args(args, 2)?; let panel = parse_panel(&args[0])?; @@ -396,7 +490,7 @@ pub fn default_registry() -> CmdRegistry { r.register_nullary(|| Box::new(DeleteViewAtCursor)); // ── Tile select ────────────────────────────────────────────────────── - r.register_pure(&MoveTileCursor(0), |args| { + r.register_pure(&MoveTileCursor(0), MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let delta = args[0].parse::().map_err(|e| e.to_string())?; Ok(Box::new(MoveTileCursor(delta))) @@ -406,6 +500,7 @@ pub fn default_registry() -> CmdRegistry { &TileAxisOp { axis: Some(crate::view::Axis::Row), }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let axis = parse_axis(&args[0])?; @@ -423,6 +518,7 @@ pub fn default_registry() -> CmdRegistry { &AppendChar { buffer: String::new(), }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; Ok(Box::new(AppendChar { @@ -434,6 +530,7 @@ pub fn default_registry() -> CmdRegistry { &PopChar { buffer: String::new(), }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; Ok(Box::new(PopChar { @@ -454,13 +551,14 @@ pub fn default_registry() -> CmdRegistry { edit_mode: AppMode::editing(), opened: None, }, + MaxArgs::Any, |args| { if args.len() < 3 { return Err("commit-cell-edit requires a mode, value, and coords".into()); } let edit_mode = parse_mode_name(&args[0])?; Ok(CommitAndAdvance { - key: parse_cell_key_from_args(&args[2..]), + key: parse_cell_key_from_args(&args[2..])?, value: args[1].clone(), advance: AdvanceDir::Down, cursor: CursorState::default(), @@ -479,6 +577,7 @@ pub fn default_registry() -> CmdRegistry { edit_mode: AppMode::editing(), opened: None, }, + MaxArgs::AtMost(1), |_| Err("commit-and-advance-right requires context".into()), Some(|args, ctx| commit_at_cursor(args, ctx, AdvanceDir::Right)), ); @@ -486,6 +585,7 @@ pub fn default_registry() -> CmdRegistry { &CommitBuffer { kind: CommitKind::Category, }, + MaxArgs::AtMost(1), |args| { require_args(args, 1)?; let kind = parse_commit_kind(&args[0], &AppMode::Normal)?; @@ -534,6 +634,7 @@ pub(super) mod tests { vec!["a"], vec!["Region"], vec!["Region", "East"], + vec!["Region", "row"], vec!["Region", "East", "1"], vec!["Profit = 1"], vec![",.2f"], @@ -544,6 +645,7 @@ pub(super) mod tests { vec!["editing", "5", "Region/East"], vec!["formula", "false", "false"], vec!["Region/East"], + vec!["1", "Region/East"], ] .into_iter() .map(|v| v.into_iter().map(String::from).collect()) @@ -592,6 +694,87 @@ pub(super) mod tests { ); } + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn no_command_silently_drops_an_argument_it_was_given() { + let r = default_registry(); + let wb = two_cat_model(); + let layout = make_layout(&wb); + let reg = make_registry(); + let ctx = make_ctx(&wb, &layout, ®); + let variadic = [ + "add-items", + "set-cell", + "set-format", + "clear-cell", + "yank", + "paste", + "drill-into-cell", + "commit-cell-edit", + ]; + // Their own refusal states the shape, and it runs before the rule. + let own_shape = ["add-formula", "fit-measure", "commit-and-advance-right"]; + let mut checked = 0; + for name in r.names().collect::>() { + if variadic.contains(&name) { + continue; + } + for args in candidate_args() { + // Four more than any list accepted, so an optional argument + // cannot absorb them; the last one is surplus whatever the command. + let mut more = args.clone(); + more.extend(["EXTRA1", "EXTRA2", "EXTRA3", "EXTRA4"].map(String::from)); + let arms = [ + r.parse(name, &args).map(|_| r.parse(name, &more)), + r.interactive(name, &args, &ctx) + .map(|_| r.interactive(name, &more, &ctx)), + ]; + for extended in arms.into_iter().flatten() { + checked += 1; + match extended { + Ok(_) => panic!("`{name} {more:?}` dropped arguments without a word"), + Err(e) if own_shape.contains(&name) || e.contains("\"EXTRA4\"") => {} + Err(e) => { + panic!("`{name}` refused an extra argument without naming it: {e}") + } + } + } + } + } + assert!(checked > 100, "only {checked} accepted lists were extended"); + } + + /// Codex on PR #316: a variadic coordinate list dropped a token that was no + /// coordinate. Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_malformed_coordinate_is_refused_not_dropped() { + let lines = [ + "set-cell 1 Type/Food unexpected", + "clear-cell Type/Food unexpected", + "yank Type/Food unexpected", + "paste Type/Food unexpected", + "drill-into-cell Type/Food unexpected", + "commit-cell-edit editing 5 Type/Food unexpected", + ]; + for line in lines { + let err = crate::command::parse_line(line) + .err() + .unwrap_or_else(|| panic!("`{line}` dropped \"unexpected\" without a word")); + assert!( + err.contains("\"unexpected\""), + "`{line}` did not name it: {err}" + ); + } + for half_empty in ["set-cell 1 Type/", "clear-cell /Food"] { + assert!( + crate::command::parse_line(half_empty).is_err(), + "`{half_empty}`" + ); + } + let control = "set-cell 1 Type/Food Month/Jan . clear-cell Type/Food"; + assert!(crate::command::parse_line(control).is_ok(), "the control"); + } + /// A variant mix-up shows up as a **collision**, not a mismatch: the wrong entry /// takes its sibling's name and the sibling vanishes. Uniqueness catches it. #[test] diff --git a/crates/improvise-command/src/command/cmd/text_buffer.rs b/crates/improvise-command/src/command/cmd/text_buffer.rs index 300b1336..50fc5ea3 100644 --- a/crates/improvise-command/src/command/cmd/text_buffer.rs +++ b/crates/improvise-command/src/command/cmd/text_buffer.rs @@ -216,6 +216,110 @@ mod tests { app.apply_effects(effects); } + fn cell(wb: &crate::workbook::Workbook, ty: &str, month: &str) -> Option { + use crate::model::cell::{CellKey, CellValue}; + let key = CellKey::new(vec![ + ("Type".into(), ty.into()), + ("Month".into(), month.into()), + ]); + match wb.model.get_cell(&key) { + Some(CellValue::Number(n)) => Some(*n), + _ => None, + } + } + + /// An app whose cursor (0,0) is on Food/Jan = 1, with Clothing/Feb = 2 beside it. + fn app_with_two_cells() -> crate::ui::app::App { + use crate::model::cell::{CellKey, CellValue}; + let mut app = wizard_test_app(); + for (ty, month, n) in [("Food", "Jan", 1.0), ("Clothing", "Feb", 2.0)] { + let key = CellKey::new(vec![ + ("Type".into(), ty.into()), + ("Month".into(), month.into()), + ]); + app.model_state + .workbook + .model + .set_cell(key, CellValue::Number(n)); + } + app + } + + /// PR #316 review: a malformed coordinate on `:` fell into the cursor fallback + /// and wrote the CURSOR's cell. Evidence: `context/test-evidence/cli.md`. + #[test] + fn colon_refuses_a_malformed_coordinate_and_leaves_the_cursor_cell_alone() { + let lines = [ + "clear-cell Type/Clothing Month/Feb junk", + "yank Type/Clothing Month/Feb junk", + "paste Type/Clothing Month/Feb junk", + "drill-into-cell Type/Clothing Month/Feb junk", + ]; + for line in lines { + let mut app = app_with_two_cells(); + app.view_state.yanked = Some(crate::model::cell::CellValue::Number(9.0)); + let before = app.view_state.yanked.clone(); + run_colon_command(&mut app, line); + assert!(app.last_batch_refused(), "`:{line}` was not refused"); + assert!( + app.view_state.status_msg.contains("\"junk\""), + "`:{line}` did not name the token: {:?}", + app.view_state.status_msg + ); + let wb = &app.model_state.workbook; + assert_eq!( + cell(wb, "Food", "Jan"), + Some(1.0), + "`:{line}` wrote the cursor cell" + ); + assert_eq!( + cell(wb, "Clothing", "Feb"), + Some(2.0), + "`:{line}` wrote the named cell" + ); + assert_eq!(app.view_state.yanked, before, "`:{line}` yanked"); + assert!(app.drill_state.is_none(), "`:{line}` opened a drill"); + } + } + + /// The fallback still answers for coordinates that were OMITTED. + #[test] + fn colon_clear_cell_with_no_coordinates_still_uses_the_cursor() { + let mut app = app_with_two_cells(); + run_colon_command(&mut app, "clear-cell"); + let wb = &app.model_state.workbook; + assert_eq!(cell(wb, "Food", "Jan"), None); + assert_eq!(cell(wb, "Clothing", "Feb"), Some(2.0)); + } + + /// A later `:` segment sees what an earlier one made, as `improvise cmd` does: + /// the line is built before either half runs. Evidence: `context/test-evidence/cli.md`. + #[test] + fn colon_lets_a_later_segment_use_what_an_earlier_one_created() { + let mut app = wizard_test_app(); + run_colon_command(&mut app, "create-view Second . switch-view Second"); + assert!(!app.last_batch_refused(), "{:?}", app.view_state.status_msg); + assert_eq!(app.view_state.active_view, "Second"); + + let mut app = wizard_test_app(); + run_colon_command(&mut app, "add-item Type Tools . set-page Type Tools"); + assert!(!app.last_batch_refused(), "{:?}", app.view_state.status_msg); + } + + /// The control: a name NOTHING creates is still refused, on `:` too. + #[test] + fn colon_still_refuses_a_name_nothing_creates() { + for line in [ + "switch-view Nowhere", + "set-page Type Nowhere", + "hide-item Regoin Food", + ] { + let mut app = wizard_test_app(); + run_colon_command(&mut app, line); + assert!(app.last_batch_refused(), "`:{line}` was not refused"); + } + } + /// improvise-toc: `StartImportWizard` swaps the mode only on success, so a /// per-type answer snapped `:import` straight back out of the wizard. #[test] @@ -271,6 +375,43 @@ mod tests { ); } + /// improvise-31oe.2 on the `:` line: the refusal is on the status line, + /// naming what is missing. Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_colon_line_naming_something_missing_refuses_on_the_status_line() { + for (line, missing) in [ + ("set-axis Regoin row", "Regoin"), + ("set-axis Type sideways", "sideways"), + ("set-page Type Nowhere", "Nowhere"), + ("create-view By area", "\"area\""), + ] { + let mut app = wizard_test_app(); + run_colon_command(&mut app, line); + let said = &app.view_state.status_msg; + assert!( + app.last_batch_refused(), + "`:{line}` was not refused: {said:?}" + ); + assert!( + said.contains(missing), + "`:{line}` did not name {missing}: {said:?}" + ); + assert!(matches!(app.view_state.mode, AppMode::Normal), "`:{line}`"); + } + } + + /// improvise-31oe.3 on the `:` line. + #[test] + fn a_colon_line_reads_a_pipe_quoted_name() { + let mut app = wizard_test_app(); + run_colon_command(&mut app, "create-view |By area|"); + let views: Vec<&String> = app.model_state.workbook.views.keys().collect(); + assert!( + app.model_state.workbook.views.contains_key("By area"), + "{views:?}" + ); + } + /// A command that does not touch the mode still returns to Normal. #[test] fn colon_plain_command_returns_to_normal() { @@ -372,9 +513,12 @@ impl Cmd for ExecuteCommand { // Built against the live cursor, exactly as a script's is — improvise-6uva. let built: Result>, String> = crate::command::parse::segments(&raw) - .into_iter() - .map(|seg| crate::command::parse::parse_segment_interactive(seg, ctx)) - .collect::, _>>() + .and_then(|segments| { + segments + .into_iter() + .map(|seg| crate::command::parse::parse_segment_interactive(seg, ctx)) + .collect::, _>>() + }) .map(|built| built.into_iter().flatten().collect()); match built { Ok(cmds) => { diff --git a/crates/improvise-command/src/command/cmd/wizard.rs b/crates/improvise-command/src/command/cmd/wizard.rs index cb145dfc..66ccb650 100644 --- a/crates/improvise-command/src/command/cmd/wizard.rs +++ b/crates/improvise-command/src/command/cmd/wizard.rs @@ -65,6 +65,7 @@ fn review_proposal_keys(key: KeyCode) -> Vec> { fn configure_date_keys(key: KeyCode) -> Vec> { match key { KeyCode::Char(' ') => op(WizardOp::ToggleDateComponent), + KeyCode::Char('s') => op(WizardOp::ToggleDateShape), KeyCode::Enter => op(WizardOp::Advance), _ => cursor_keys(key), } diff --git a/crates/improvise-command/src/command/parse.rs b/crates/improvise-command/src/command/parse.rs index 3f236d47..6f68a90b 100644 --- a/crates/improvise-command/src/command/parse.rs +++ b/crates/improvise-command/src/command/parse.rs @@ -1,5 +1,10 @@ //! Quasi-lisp prefix command parser: `word arg1 arg2 …`, `.` between commands, -//! `Category/Item` coordinate pairs, `"quoted strings"`. +//! `Category/Item` coordinate pairs, `"quoted strings"` and `|pipe-quoted names|`. + +use std::iter::Peekable; +use std::str::CharIndices; + +use improvise_formula::quote::unescape_pipe_body; use super::cmd::{Cmd, CmdContext, CmdRegistry, default_registry}; @@ -11,39 +16,19 @@ pub fn parse_line(line: &str) -> Result>, String> { /// Parse a line into commands using a given registry. pub fn parse_line_with(registry: &CmdRegistry, line: &str) -> Result>, String> { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with("//") { - return Ok(vec![]); - } - - let mut commands = Vec::new(); - for segment in split_on_dot(line) { - let segment = segment.trim(); - if segment.is_empty() { - continue; - } - let tokens = tokenize(segment); - if tokens.is_empty() { - continue; - } - let word = &tokens[0]; - let args = &tokens[1..]; - commands.push(registry.parse(word, args)?); - } - Ok(commands) + groups(line.trim())? + .iter() + .map(|words| registry.parse(&words[0].text, &texts(&words[1..]))) + .collect() } /// The command segments of a line, with comments and blank segments dropped. -pub fn segments(line: &str) -> Vec<&str> { +pub fn segments(line: &str) -> Result, String> { let line = line.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with("//") { - return Vec::new(); - } - split_on_dot(line) - .into_iter() - .map(str::trim) - .filter(|s| !s.is_empty()) - .collect() + Ok(groups(line)? + .iter() + .map(|words| &line[words[0].start..words[words.len() - 1].end]) + .collect()) } /// Build one segment's command against a LIVE context — `context/code/cli.md`. @@ -51,7 +36,7 @@ pub fn parse_segment_interactive( segment: &str, ctx: &CmdContext, ) -> Result>, String> { - let tokens = tokenize(segment.trim()); + let tokens = texts(&lex(segment.trim())?); let Some(word) = tokens.first() else { return Ok(None); }; @@ -67,65 +52,99 @@ pub fn parse_segment_interactive( }) } -/// Split a line on ` . ` separators (dot must be a standalone word, -/// surrounded by whitespace or at line boundaries). Respects quoted strings. -fn split_on_dot(line: &str) -> Vec<&str> { - let mut segments = Vec::new(); - let mut start = 0; - let mut in_quote = false; - let bytes = line.as_bytes(); - - for (i, c) in line.char_indices() { - match c { - '"' => in_quote = !in_quote, - '.' if !in_quote => { - let before_ws = i == 0 || bytes[i - 1].is_ascii_whitespace(); - let after_ws = i + 1 >= bytes.len() || bytes[i + 1].is_ascii_whitespace(); - if before_ws && after_ws { - segments.push(&line[start..i]); - start = i + 1; - } - } - _ => {} - } - } - segments.push(&line[start..]); - segments +/// One word of a line and the byte span it was read from. A `separator` is a +/// bare `.`; a quoted one is an argument like any other. +struct Word { + text: String, + start: usize, + end: usize, + separator: bool, } -/// Tokenize a command segment into words, handling quoted strings. -fn tokenize(input: &str) -> Vec { - let mut tokens = Vec::new(); - let mut chars = input.chars().peekable(); +fn texts(words: &[Word]) -> Vec { + words.iter().map(|w| w.text.clone()).collect() +} - while let Some(&c) = chars.peek() { +/// The words of a trimmed line, split at separators, blank commands and +/// comment lines dropped. +fn groups(line: &str) -> Result>, String> { + if line.starts_with('#') || line.starts_with("//") { + return Ok(Vec::new()); + } + let mut groups: Vec> = vec![Vec::new()]; + for word in lex(line)? { + match word.separator { + true => groups.push(Vec::new()), + false => groups.last_mut().expect("never empty").push(word), + } + } + groups.retain(|g| !g.is_empty()); + Ok(groups) +} + +/// Split `line` into words. The quoting rules: `context/code/cli.md` §14. +fn lex(line: &str) -> Result, String> { + let mut words = Vec::new(); + let mut chars = line.char_indices().peekable(); + while let Some(&(start, c)) = chars.peek() { if c.is_whitespace() { chars.next(); continue; } - if c == '"' { - chars.next(); // consume opening quote - let mut s = String::new(); - for ch in chars.by_ref() { - if ch == '"' { - break; - } - s.push(ch); + let (text, bare) = match c { + '"' => (double_quoted(&mut chars), false), + _ => bare_word(&mut chars, line)?, + }; + let end = chars.peek().map_or(line.len(), |&(i, _)| i); + words.push(Word { + separator: bare && text == ".", + text, + start, + end, + }); + } + Ok(words) +} + +/// `"…"` read verbatim: no escapes, and a `|` inside stays a `|`. +fn double_quoted(chars: &mut Peekable) -> String { + chars.next(); + chars + .by_ref() + .map(|(_, c)| c) + .take_while(|&c| c != '"') + .collect() +} + +/// Text up to whitespace, each `|…|` piece unquoted in place; `bare` is false +/// once a piece was quoted, so a quoted `|.|` is an argument and not a separator. +fn bare_word(chars: &mut Peekable, line: &str) -> Result<(String, bool), String> { + let mut text = String::new(); + let mut bare = true; + while let Some((at, c)) = chars.next_if(|&(_, c)| !c.is_whitespace()) { + match c { + '|' => { + text.push_str(&pipe_body(chars, line, at)?); + bare = false; } - tokens.push(s); - } else { - let mut s = String::new(); - while let Some(&ch) = chars.peek() { - if ch.is_whitespace() { - break; - } - s.push(ch); - chars.next(); - } - tokens.push(s); + _ => text.push(c), } } - tokens + Ok((text, bare)) +} + +/// The body of the `|…|` opened at `open`, escapes applied by the one shared +/// reader (gotcha 34). +fn pipe_body(chars: &mut Peekable, line: &str, open: usize) -> Result { + let mut body = String::new(); + while let Some((_, c)) = chars.next() { + match c { + '|' => return Ok(unescape_pipe_body(&body)), + '\\' => body.extend(std::iter::once(c).chain(chars.next().map(|(_, e)| e))), + _ => body.push(c), + } + } + Err(format!("unterminated |…| in `{}`", &line[open..])) } #[cfg(test)] @@ -302,11 +321,14 @@ mod tests { #[test] fn segments_drops_comments_and_blanks() { - assert_eq!(segments("# a comment"), Vec::<&str>::new()); - assert_eq!(segments("// also a comment"), Vec::<&str>::new()); - assert_eq!(segments(" "), Vec::<&str>::new()); - assert_eq!(segments("a . . b"), vec!["a", "b"]); - assert_eq!(segments("move-selection 2 0"), vec!["move-selection 2 0"]); + assert_eq!(segments("# a comment"), Ok(Vec::<&str>::new())); + assert_eq!(segments("// also a comment"), Ok(Vec::<&str>::new())); + assert_eq!(segments(" "), Ok(Vec::<&str>::new())); + assert_eq!(segments("a . . b"), Ok(vec!["a", "b"])); + assert_eq!( + segments("move-selection 2 0"), + Ok(vec!["move-selection 2 0"]) + ); } /// Evidence: `context/test-evidence/cli.md`. @@ -347,4 +369,105 @@ mod tests { .expect_err("an unknown word is refused"); assert!(err.contains("no-such-command"), "got: {err}"); } + + // ── pipe quoting and arity (improvise-31oe.3) ───────────────────────── + + /// The one command `line` builds, as its `Debug` shows it. + fn built(line: &str) -> String { + let cmds = parse_line(line).unwrap_or_else(|e| panic!("`{line}` must parse: {e}")); + assert_eq!(cmds.len(), 1, "`{line}` is one command"); + format!("{:?}", cmds[0]) + } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_pipe_quoted_argument_is_one_argument_without_its_pipes() { + assert_eq!( + built("create-view |By area|"), + r#"CreateViewCmd(["By area"])"# + ); + assert_eq!( + built("set-page |Sales Region| |East Coast|"), + r#"SetPageCmd(["Sales Region", "East Coast"])"# + ); + } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_pipe_quoted_argument_reads_the_escapes_improv_does() { + assert_eq!(built(r"create-view |a\|b|"), r#"CreateViewCmd(["a|b"])"#); + assert_eq!( + built(r"create-view |back\\slash|"), + r#"CreateViewCmd(["back\\slash"])"# + ); + assert_eq!( + built(r"create-view |two\nlines|"), + r#"CreateViewCmd(["two\nlines"])"# + ); + } + + /// A coordinate quotes each half, as a `.improv` data line does. + #[test] + fn a_coordinate_may_pipe_quote_either_half() { + assert_eq!( + built("set-cell 5 |Sales Region|/|East Coast| Measure/Revenue"), + r#"SetCellCmd(["5", "Sales Region/East Coast", "Measure/Revenue"])"# + ); + } + + #[test] + fn a_dot_inside_pipes_does_not_split_the_line() { + assert_eq!(built("create-view |a . b|"), r#"CreateViewCmd(["a . b"])"#); + assert_eq!( + segments("create-view |a . b|"), + Ok(vec!["create-view |a . b|"]) + ); + } + + /// Double quotes stay verbatim: formula text carries its own pipes. + #[test] + fn a_double_quoted_argument_keeps_the_pipes_inside_it() { + assert_eq!( + built(r#"add-formula Measure "Net = |Gross Rev| - Cost""#), + r#"AddFormulaCmd(["Measure", "Net = |Gross Rev| - Cost"])"# + ); + } + + /// `context/code/cli.md` §14: only a BARE `.` separates commands. A quoted one + /// is an argument, so `|.|` and `"."` name a thing called `.`. + #[test] + fn a_quoted_dot_is_an_argument_not_a_separator() { + for line in ["create-view |.|", "create-view \".\""] { + let groups = groups(line).expect("lexes"); + assert_eq!(groups.len(), 1, "`{line}` split into commands"); + assert_eq!(texts(&groups[0]), ["create-view", "."], "`{line}`"); + } + assert_eq!(groups("create-view A . create-view B").unwrap().len(), 2); + } + + #[test] + fn an_unterminated_pipe_is_refused() { + let err = parse_line("create-view |By area").expect_err("an open pipe is refused"); + assert!( + err.contains("|By area"), + "the refusal names the text: {err}" + ); + } + + /// Evidence: `context/test-evidence/cli.md`. + #[test] + fn a_command_given_more_arguments_than_it_takes_is_refused() { + let err = parse_line("create-view By area").expect_err("two words for one argument"); + assert!(err.starts_with("create-view:"), "names the command: {err}"); + assert!(err.contains("\"area\""), "names the extra argument: {err}"); + let err = parse_line("set-axis Type row sideways").expect_err("a third argument"); + assert!( + err.contains("\"sideways\""), + "names the extra argument: {err}" + ); + assert!( + parse_line("create-view |By area|").is_ok(), + "the control parses" + ); + } } diff --git a/crates/improvise-command/src/ui/app.rs b/crates/improvise-command/src/ui/app.rs index df08149c..47425e2b 100644 --- a/crates/improvise-command/src/ui/app.rs +++ b/crates/improvise-command/src/ui/app.rs @@ -24,7 +24,7 @@ use crate::persistence; use crate::ui::effect::{EffectKind, ModelChange, ViewTarget}; use crate::ui::project::Dirty; use crate::ui::viewport::{ - compute_col_widths, compute_row_header_width, compute_visible_cols, grid_width, + GridSummary, compute_row_header_width, compute_visible_cols, grid_summary, grid_width, }; use crate::view::{GridLayout, View}; use crate::workbook::Workbook; @@ -50,17 +50,17 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 { .filter(|item| !hidden.is_some_and(|h| h.contains(item))) .collect(); drawn.hash(&mut h); - // Off the drawn items, not `Category::groups`: `add_item_in_group` does not - // populate that, and a group with nothing drawn under it is not shape. + // Off the drawn items' chains, not `Category::groups`: a group with nothing + // drawn under it is not shape, and a fold at any depth is. let mut collapsed: Vec<&str> = model .category(cat) .into_iter() - .flat_map(|c| drawn.iter().filter_map(|item| c.items.get(item))) - .filter_map(|item| item.group.as_deref()) - .filter(|group| { - view.collapsed_groups - .get(cat) - .is_some_and(|c| c.contains(*group)) + .flat_map(|c| drawn.iter().map(move |item| c.item_chain(item))) + .flat_map(|chain| { + (0..chain.len()) + .filter(|&d| view.is_group_collapsed(cat, &chain[..=d])) + .map(|d| chain[d]) + .collect::>() }) .collect(); collapsed.sort_unstable(); @@ -77,15 +77,14 @@ fn view_shape(model: &crate::model::types::Model, view: &View) -> u64 { fn view_frame(model: &crate::model::types::Model, view: &View) -> u64 { // `with_frozen_records`, not `new`: `new` does NOT apply `prune_empty`, and a // pruned row is exactly the kind of reframing this observation exists for. - frame_of( - &crate::view::layout::GridLayout::with_frozen_records(model, view, None), - view, - ) + frame_of(&crate::view::layout::GridLayout::with_frozen_records( + model, view, None, + )) } /// The same hash off a layout already built. The batch's opening basis is free /// this way — `context/code/command.md` (improvise-mcq4). -fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { +fn frame_of(layout: &crate::view::layout::GridLayout) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); layout.row_cats.hash(&mut h); @@ -95,14 +94,17 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { for entry in entries { match entry { crate::view::layout::AxisEntry::GroupHeader { + depth, cat_name, group_name, + collapsed, .. } => { 0u8.hash(&mut h); + depth.hash(&mut h); cat_name.hash(&mut h); group_name.hash(&mut h); - view.is_group_collapsed(cat_name, group_name).hash(&mut h); + collapsed.hash(&mut h); } crate::view::layout::AxisEntry::DataItem { tuple, collapsed } => { 1u8.hash(&mut h); @@ -111,9 +113,10 @@ fn frame_of(layout: &crate::view::layout::GridLayout, view: &View) -> u64 { } // Drawn as nothing, but it ends a group's run (improvise-gd83), // so where it sits is what a row's group attribution depends on. - crate::view::layout::AxisEntry::GroupEnd { level } => { + crate::view::layout::AxisEntry::GroupEnd { level, depth } => { 2u8.hash(&mut h); level.hash(&mut h); + depth.hash(&mut h); } } } @@ -204,7 +207,16 @@ pub(crate) fn document_config(workbook: &Workbook) -> u64 { axis.hash(&mut h); } sorted_pairs(&view.hidden_items).hash(&mut h); - sorted_pairs(&view.collapsed_groups).hash(&mut h); + let mut depths: Vec<_> = view.expand_depth.iter().collect(); + depths.sort_unstable(); + depths.hash(&mut h); + let mut folds: Vec<_> = view + .group_folds + .iter() + .flat_map(|(cat, groups)| groups.iter().map(move |(g, fold)| (cat, g, fold))) + .collect(); + folds.sort_unstable(); + folds.hash(&mut h); let mut pages: Vec<(&String, &String)> = view.page_selections.iter().collect(); pages.sort_unstable(); pages.hash(&mut h); @@ -422,6 +434,20 @@ impl Default for ModelState { } } +/// What a layout was built from other than the model. +#[derive(Debug, Default, PartialEq, Eq)] +struct LayoutBasis { + view: String, + /// The address of the frozen records, never dereferenced. + drill: Option, +} + +/// What the keypress path measures of a built layout. +pub(crate) struct GridGeometry { + pub summary: GridSummary, + pub row_header_width: u16, +} + /// Top-level application state: `model_state`, `view_state`, and a residue of /// fields tagged below. The five residue tags are in `context/code/command.md`. pub struct App { @@ -441,8 +467,21 @@ pub struct App { pub term_width: u16, pub term_height: u16, /// **Derived cache**: a pure function of the slices, rebuilt by - /// `rebuild_layout()`. Owned by `App` because the rebuild is the host's job. + /// `rebuild_layout()`; a writer that bypasses effects must call it (felo.13). pub layout: GridLayout, + /// How many times `rebuild_layout` has run: what a keypress that changes + /// neither the model nor the view must leave alone (improvise-felo.13). + layout_builds: usize, + /// How many times the grid's column widths were measured, for the same reason. + width_passes: std::cell::Cell, + /// The widths of the layout above, measured on first use and dropped with it. + geometry: std::cell::OnceCell, + /// What the layout above was built from besides the model: the view it drew + /// and the drill snapshot it froze. `layout_current` compares it. + layout_basis: LayoutBasis, + /// The model changed since the layout was built. Set by the one writer that + /// does not rebuild at once. + layout_stale: bool, /// **Transient**: set during `apply_effects` to skip the rest of the batch, /// reset at the start of every call. Reach it through `AbortChain`. pub abort_effects: bool, @@ -541,6 +580,11 @@ impl App { term_width: 80, term_height: 24, layout, + layout_builds: 0, + width_passes: std::cell::Cell::new(0), + geometry: std::cell::OnceCell::new(), + layout_basis: LayoutBasis::default(), + layout_stale: false, abort_effects: false, batch_aborted: false, batch_refused: false, @@ -554,6 +598,7 @@ impl App { keymap_set: KeymapSet::default_keymaps(), storage, }; + app.layout_basis = app.current_layout_basis(); app.settle_normal_mode(); app } @@ -598,6 +643,7 @@ impl App { self.view_state.view_back_stack.clear(); self.view_state.view_forward_stack.clear(); self.model_state.workbook = workbook; + self.layout_stale = true; // Every client's mirror is scoped to the outgoing document too, and an // effect cannot reach the session list. The generation is how it hears. self.document_generation += 1; @@ -862,7 +908,7 @@ impl App { fn opening_view_frame(&self) -> u64 { let (_, view) = self.resolve_view(self.view_state.active_view.as_str()); match self.drill_state.is_none() { - true => frame_of(&self.layout, view), + true => frame_of(&self.layout), // A drill froze the record list, so the held layout is not what // `view_frame` would build and the two must not be compared. false => view_frame(&self.model_state.workbook.model, view), @@ -888,6 +934,42 @@ impl App { let frozen = self.drill_state.as_ref().map(|s| Rc::clone(&s.records)); self.layout = GridLayout::with_frozen_records(&self.model_state.workbook.model, view, frozen); + self.layout_builds += 1; + self.layout_stale = false; + self.layout_basis = self.current_layout_basis(); + self.geometry = std::cell::OnceCell::new(); + } + + /// Rebuild only if the model, the view drawn or the drill snapshot moved; a + /// cursor key moves none of them (layout-cost.md). + fn refresh_layout(&mut self) { + if self.layout_stale || self.layout_basis != self.current_layout_basis() { + self.rebuild_layout(); + } + } + + fn current_layout_basis(&self) -> LayoutBasis { + LayoutBasis { + view: self.view_name().to_string(), + drill: self + .drill_state + .as_ref() + .map(|d| Rc::as_ptr(&d.records) as usize), + } + } + + /// The measurements a keypress needs of the grid: column widths and the row + /// header. Counted, so a test can see whether a keypress paid for them. + pub(crate) fn grid_geometry(&self) -> &GridGeometry { + self.geometry.get_or_init(|| { + self.width_passes.set(self.width_passes.get() + 1); + let model = &self.model_state.workbook.model; + let plan = FormatPlan::of(model, self.view()); + GridGeometry { + summary: grid_summary(model, &self.layout, &plan), + row_header_width: compute_row_header_width(&self.layout), + } + }) } /// Editable text for the cell under the cursor: staged drill edit, else the @@ -968,12 +1050,10 @@ impl App { display_value: self.display_value_at_cursor(), visible_rows: (self.term_height as usize).saturating_sub(8), visible_cols: { - let model = &self.model_state.workbook.model; - let col_widths = compute_col_widths(model, layout, &FormatPlan::of(model, view)); - let row_header_width = compute_row_header_width(layout); + let geometry = self.grid_geometry(); compute_visible_cols( - &col_widths, - row_header_width, + &geometry.summary.col_widths, + geometry.row_header_width, self.grid_width(), cursor.col_offset, ) @@ -1104,7 +1184,8 @@ impl App { // is read only behind the epoch gate — `context/code/command.md`. let reshaped = self.last_reshaped_views.clone(); self.last_reframed_views.extend(reshaped); - self.rebuild_layout(); + self.layout_stale |= touches_model; + self.refresh_layout(); // After the rebuild, or a batch that crosses into or out of records mode // settles against the grid it started on — `context/code/command.md`. self.settle_normal_mode(); @@ -1184,7 +1265,7 @@ impl App { /// Resolve one keypress through the keymap and apply what it produces. The /// projection half is factored out; `ui::reduce` is the named door (improvise-gxi). pub fn dispatch_key(&mut self, key: KeyEvent) -> Dirty { - self.rebuild_layout(); + self.refresh_layout(); // An armed prefix key takes priority. Resolve it back to its // sub-keymap through the keymap set (improvise-jc8). @@ -1224,7 +1305,12 @@ impl App { } pub fn start_import_wizard(&mut self, json: serde_json::Value) { - self.wizard = Some(ImportWizard::new(json)); + self.start_import(crate::import::wizard::ImportPipeline::new(json)); + } + + /// Open the wizard over a pipeline the host built, sources and all. + pub fn start_import(&mut self, pipeline: crate::import::wizard::ImportPipeline) { + self.wizard = Some(ImportWizard::from_pipeline(pipeline)); self.view_state.mode = AppMode::ImportWizard; } @@ -1577,6 +1663,32 @@ mod tests { app } + /// improvise-felo.13: `StartDrill` is `ViewOnly`, so a batch of it alone moves + /// the layout with no `Model` effect to mark it stale. Evidence: layout-cost.md. + #[test] + fn a_drill_snapshot_replaced_alone_rebuilds_the_layout() { + let mut app = records_app_for_drill(); + assert_eq!(app.layout.row_count(), 3); + let rows: Vec<_> = app.layout.records.as_ref().unwrap().to_vec(); + let start = |app: &mut App, n: usize| { + let snapshot = rows.iter().take(n).cloned().collect(); + app.apply_effects(vec![Box::new(crate::ui::effect::StartDrill(snapshot))]); + }; + start(&mut app, 1); + assert_eq!( + app.layout.row_count(), + 1, + "the layout draws the frozen snapshot" + ); + let builds = app.layout_builds; + start(&mut app, 2); + assert!( + app.layout_builds > builds, + "no rebuild for a second drill snapshot" + ); + assert_eq!(app.layout.row_count(), 2); + } + /// The opening basis may be read off `App::layout` only when no drill froze /// it. Evidence: `context/test-evidence/command.md`. #[test] @@ -1881,6 +1993,71 @@ mod tests { App::new(wb, None) } + /// improvise-felo.13: context/test-evidence/layout-cost.md. + #[test] + fn a_cursor_key_rebuilds_neither_the_layout_nor_the_widths() { + let mut app = two_col_model(); + let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + app.dispatch_key(down()); + let (builds, widths) = (app.layout_builds, app.width_passes.get()); + app.dispatch_key(down()); + app.dispatch_key(down()); + assert_eq!(app.cursor().selected.0, 2, "the keys moved the cursor"); + assert_eq!(app.layout_builds, builds, "layout rebuilt on a cursor key"); + assert_eq!(app.width_passes.get(), widths, "widths remeasured"); + } + + /// improvise-felo.13: the control for the test above, and the guard on + /// invalidation: an edit must rebuild, and the widths must follow the value. + #[test] + fn an_edit_rebuilds_and_remeasures_so_the_widths_follow_the_value() { + use crate::ui::effect::SetCell; + let mut app = two_col_model(); + let down = || KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + app.dispatch_key(down()); + let narrow = app.grid_geometry().summary.col_widths.clone(); + let (builds, widths) = (app.layout_builds, app.width_passes.get()); + let key = CellKey::new(vec![("Row".into(), "A".into()), ("Col".into(), "X".into())]); + let long = CellValue::Text("a value far wider than a column".into()); + app.apply_effects(vec![Box::new(SetCell(key, long))]); + assert!( + app.layout_builds > builds, + "an edit must rebuild the layout" + ); + app.dispatch_key(down()); + assert!(app.width_passes.get() > widths, "an edit must remeasure"); + let model = &app.model_state.workbook.model; + let fresh = crate::ui::viewport::compute_col_widths( + model, + &app.layout, + &FormatPlan::of(model, app.view()), + ); + assert_eq!( + app.grid_geometry().summary.col_widths, + fresh, + "stale widths" + ); + assert_ne!( + fresh, narrow, + "the edit widened a column, or this proves nothing" + ); + } + + /// improvise-felo.13: a `ViewOnly` effect that changes which view is drawn + /// must still rebuild, so skipping the rebuild is not keyed on `EffectKind` alone. + #[test] + fn switching_view_rebuilds_though_the_effect_is_view_only() { + let mut app = two_col_model(); + app.model_state.workbook.create_view("Second"); + app.rebuild_layout(); + let builds = app.layout_builds; + app.apply_effects(vec![Box::new(crate::ui::effect::SwitchView( + "Second".into(), + ))]); + assert_eq!(app.view_state.active_view.as_str(), "Second"); + assert!(app.layout_builds > builds, "the layout still draws Default"); + } + /// improvise-htl6. Evidence: `context/test-evidence/command.md`. #[test] fn the_view_history_walks_both_ways_from_the_keyboard() { @@ -2117,6 +2294,7 @@ mod tests { .unwrap() .add_item(format!("R{i}")); } + app.rebuild_layout(); app.term_height = 28; // ~20 visible rows → delta = 15 app.cursor_mut().selected = (0, 0); app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)) @@ -2141,6 +2319,7 @@ mod tests { .unwrap() .add_item(format!("R{i}")); } + app.rebuild_layout(); app.term_height = 28; app.cursor_mut().selected = (20, 0); app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)) @@ -2160,6 +2339,7 @@ mod tests { .unwrap() .add_item(format!("R{i}")); } + app.rebuild_layout(); app.term_height = 13; // ~5 visible rows app.cursor_mut().selected = (0, 0); // G jumps to last row (row 12) @@ -2187,6 +2367,7 @@ mod tests { .unwrap() .add_item(format!("R{i}")); } + app.rebuild_layout(); app.term_height = 13; // ~5 visible rows app.cursor_mut().selected = (0, 0); // Ctrl+d scrolls by 5 rows @@ -3281,7 +3462,9 @@ mod tests { /// when any segment refused, which is what its exit code reads. fn run_line(app: &mut App, line: &str) -> bool { let mut refused = false; - for segment in crate::command::parse::segments(line) { + let segments = crate::command::parse::segments(line) + .unwrap_or_else(|e| panic!("`{line}` must lex: {e}")); + for segment in segments { let effects = { let ctx = app.cmd_context(KeyCode::Null, KeyModifiers::NONE); crate::command::parse::parse_segment_interactive(segment, &ctx) @@ -3314,7 +3497,7 @@ mod tests { /// Eleven regular categories: one short of the limit. fn one_category_short_of_the_limit() -> App { let mut wb = Workbook::new("T"); - for n in 0..11 { + for n in 0..crate::model::MAX_CATEGORIES - 1 { wb.add_category(format!("C{n}")).unwrap(); } App::new(wb, None) @@ -3425,8 +3608,9 @@ mod tests { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); app @@ -3464,12 +3648,13 @@ mod tests { fn every_refusal_site_refuses_its_failure_and_only_its_failure() { type Setup = fn() -> App; type Drive = fn(&mut App) -> bool; + let limit = format!("Maximum of {} categories", crate::model::MAX_CATEGORIES); let cases: Vec<(&str, Setup, Drive, &str, Setup, Drive)> = vec![ ( "add-category past the limit", one_category_short_of_the_limit, |app| run_line(app, "add-category A . add-category B"), - "Maximum of 12 categories", + &limit, one_category_short_of_the_limit, |app| run_line(app, "add-category A"), ), diff --git a/crates/improvise-command/src/ui/cat_tree.rs b/crates/improvise-command/src/ui/cat_tree.rs index fd538c41..b8db1ea7 100644 --- a/crates/improvise-command/src/ui/cat_tree.rs +++ b/crates/improvise-command/src/ui/cat_tree.rs @@ -137,6 +137,7 @@ mod tests { fn cat_name_works_for_both_variants() { let header = CatTreeEntry::Category { name: "Region".into(), + display: "Region".into(), item_count: 2, expanded: false, axis: crate::view::Axis::None, diff --git a/crates/improvise-command/src/ui/effect.rs b/crates/improvise-command/src/ui/effect.rs index 13fc9bfc..f69d2ff8 100644 --- a/crates/improvise-command/src/ui/effect.rs +++ b/crates/improvise-command/src/ui/effect.rs @@ -550,8 +550,14 @@ pub struct ToggleGroup { } impl Effect for ToggleGroup { fn apply(&self, app: &mut App) { + let chain = app + .model_state + .workbook + .model + .group_chain(&self.category, &self.group); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); on_view(app, &self.view, |v| { - v.toggle_group_collapse(&self.category, &self.group); + v.toggle_group_collapse(&self.category, &chain); }); } fn kind(&self) -> EffectKind { @@ -1047,6 +1053,7 @@ pub enum WizardOp { ToggleProposal, CycleProposalKind, ToggleDateComponent, + ToggleDateShape, StartFormulaEdit, DeleteFormula, ConfirmFormula, @@ -1072,6 +1079,7 @@ impl WizardOp { Self::ToggleProposal => wizard.toggle_proposal(), Self::CycleProposalKind => wizard.cycle_proposal_kind(), Self::ToggleDateComponent => wizard.toggle_date_component(), + Self::ToggleDateShape => wizard.toggle_date_shape(), Self::StartFormulaEdit => wizard.start_formula_edit(), Self::DeleteFormula => wizard.delete_formula(), Self::ConfirmFormula => wizard.confirm_formula(), @@ -1239,7 +1247,7 @@ pub struct ImportJsonHeadless { } impl Effect for ImportJsonHeadless { fn apply(&self, app: &mut App) { - use crate::import::analyzer::{FieldKind, analyze_records}; + use crate::import::analyzer::FieldKind; use crate::import::wizard::{DuplicatePolicy, ImportPipeline}; let is_csv = self @@ -1247,10 +1255,10 @@ impl Effect for ImportJsonHeadless { .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("csv")); - // Parse the file exactly once: `records` feeds analysis, `raw` is reused - // as the pipeline's value (improvise-oaq). CSV warnings go to the status. + // Parse the file exactly once, into the pipeline that reads it + // (improvise-oaq, improvise-8jk6.4). CSV warnings go to the status. let mut warnings: Vec = vec![]; - let (records, raw) = if is_csv { + let mut pipeline = if is_csv { let bytes = match app.storage.read(&self.path) { Ok(b) => b, Err(e) => { @@ -1261,8 +1269,7 @@ impl Effect for ImportJsonHeadless { match crate::import::csv_parser::parse_csv_bytes_with_warnings(&bytes) { Ok((recs, w)) => { warnings = w; - let raw = serde_json::Value::Array(recs.clone()); - (recs, raw) + ImportPipeline::from_csv(recs) } Err(e) => { refuse_and_stop(app, format!("CSV error: {e}")); @@ -1285,37 +1292,28 @@ impl Effect for ImportJsonHeadless { } }; match json_import_records(&value, self.array_path.as_deref()) { - Ok(recs) => (recs, value), + Ok(recs) => { + let mut pipeline = ImportPipeline::new(value); + pipeline.selected_path = self.array_path.clone().unwrap_or_default(); + pipeline.records = recs; + pipeline.analyze(); + pipeline + } Err(msg) => { refuse_and_stop(app, msg); return; } } }; - - let proposals = analyze_records(&records); - - let pipeline = ImportPipeline { - raw, - array_paths: vec![], - selected_path: self.array_path.as_deref().unwrap_or("").to_string(), - records, - proposals: proposals - .into_iter() - .map(|mut p| { - p.accepted = p.kind != FieldKind::Label; - p - }) - .collect(), - model_name: self - .model_name - .as_deref() - .unwrap_or("Imported Model") - .to_string(), - formulas: vec![], - // Headless: nobody to ask, so a collision refuses (improvise-cgqt). - on_duplicate: DuplicatePolicy::Refuse, - }; + pipeline.model_name = self + .model_name + .clone() + .unwrap_or_else(|| "Imported Model".to_string()); + // Headless: nobody to ask, so a collision refuses (improvise-cgqt). + pipeline.on_duplicate = DuplicatePolicy::Refuse; + for p in &mut pipeline.proposals { + p.accepted = p.kind != FieldKind::Label; + } match pipeline.build_model_with_warnings() { Ok((new_workbook, build_warnings)) => { @@ -1372,6 +1370,63 @@ impl Effect for SetPanelCursor { // ── Chain control ──────────────────────────────────────────────────────────── +/// What a command's effects name that the document must have. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Needs { + Category(String), + Item(String, String), + Group(String, String), + View(String), +} + +/// Why the document cannot satisfy `needs` in the active view, if it cannot. +fn unknown_name(app: &App, needs: &Needs) -> Option { + let workbook = &app.model_state.workbook; + let (category, rest) = match needs { + Needs::View(name) => { + return (!workbook.views.contains_key(name)) + .then(|| format!("No view named \"{name}\"")); + } + Needs::Category(c) => (c, None), + Needs::Item(c, i) => (c, Some((i, false))), + Needs::Group(c, g) => (c, Some((g, true))), + }; + let view = workbook.views.get(app.view_name())?; + if view.try_axis_of(category).is_none() { + return Some(format!("Unknown category '{category}'")); + } + let (name, is_group) = rest?; + if !is_group { + // `effective_item_names`, so a formula target is a known `_Measure` item. + let known = workbook + .model + .effective_item_names(category) + .iter() + .any(|i| i == name); + return (!known).then(|| format!("Unknown item '{name}' in '{category}'")); + } + // An item's own group registers no `Group`; a parent group has no items. + let known = workbook.model.category(category).is_some_and(|c| { + !c.items_in_group(name).is_empty() || c.groups.iter().any(|g| g.name == *name) + }); + (!known).then(|| format!("Unknown group '{name}' in '{category}'")) +} + +/// Refuse and stop unless the document has what the next effects name, asked at +/// apply time so a `:` line sees its earlier segments (`cli.md` §15). +#[derive(Debug)] +pub struct RequireKnown(pub Needs); +impl Effect for RequireKnown { + fn apply(&self, app: &mut App) { + if let Some(why) = unknown_name(app, &self.0) { + refuse_and_stop(app, why); + } + } + fn kind(&self) -> EffectKind { + EffectKind::ViewOnly + } +} + /// Skip the remaining effects in this `apply_effects` batch, for when a /// sequence's premise no longer holds. The flag resets per dispatch. #[derive(Debug)] @@ -1926,8 +1981,8 @@ mod tests { #[test] fn add_category_past_the_limit_reports_the_failure() { let mut app = test_app(); - // test_app() holds 2 regular categories; fill up to the limit of 12. - for i in 0..10 { + // test_app() holds 2 regular categories; fill up to the limit. + for i in 0..crate::model::MAX_CATEGORIES - 2 { AddCategory(format!("Cat{i}")).apply(&mut app); } app.view_state.status_msg = "Added category \"Overflow\"".to_string(); @@ -1938,7 +1993,7 @@ mod tests { .model .category("Overflow") .is_none(), - "the 13th category must not be added" + "the category past the limit must not be added" ); assert!( app.view_state.status_msg.contains("Maximum"), @@ -3875,10 +3930,11 @@ mod tests { } .apply(&mut app); assert!( - app.model_state + !app.model_state .workbook .active_view() - .is_group_collapsed("Type", "MyGroup") + .is_group_collapsed("Type", &["MyGroup"]), + "a group starts collapsed, so the first toggle opens it" ); ToggleGroup { view: ViewTarget::Active, @@ -3887,10 +3943,10 @@ mod tests { } .apply(&mut app); assert!( - !app.model_state + app.model_state .workbook .active_view() - .is_group_collapsed("Type", "MyGroup") + .is_group_collapsed("Type", &["MyGroup"]) ); } diff --git a/crates/improvise-command/src/ui/host.rs b/crates/improvise-command/src/ui/host.rs index c5ee335f..b9434734 100644 --- a/crates/improvise-command/src/ui/host.rs +++ b/crates/improvise-command/src/ui/host.rs @@ -5,7 +5,7 @@ use crate::ui::app::App; use crate::ui::effect; use crate::ui::project::{Applied, Broadcast, Dirty, SessionId, Sessions}; use crate::ui::reduce::{self, Checked, CheckedBatch}; -use improvise_protocol::{Command, Rejection}; +use improvise_protocol::{AppMode, Command, Rejection}; use improvise_protocol::{ClientFrame, ServerFrame}; @@ -87,6 +87,8 @@ impl Host { // resolution diverges (improvise-bpq2, gotcha 47). ClientFrame::Key { .. } => Vec::new(), ClientFrame::Save => self.save(id), + ClientFrame::DownloadDocument { filename } => self.download_document(id, filename), + ClientFrame::DownloadCsv { filename } => self.download_csv(id, filename), } } @@ -113,6 +115,67 @@ impl Host { } } + /// Serialize the document to `.improv` format and return the bytes for download + /// after closing the prompt in the session mirror (improvise-2mqz). + fn download_document(&mut self, id: SessionId, filename: String) -> Vec { + let Some(mut answer) = self.close_download_prompt(id, "save") else { + return Vec::new(); + }; + let bytes = + improvise_io::persistence::format_md(&self.app.model_state.workbook).into_bytes(); + answer.push(Addressed { + to: id, + msg: ServerFrame::DocumentBytes { filename, bytes }, + }); + answer + } + + /// Render the requesting session's view as downloadable CSV bytes. + /// Close its prompt mirror first; reject only if rendering fails (improvise-2mqz). + fn download_csv(&mut self, id: SessionId, filename: String) -> Vec { + let Some(view_name) = self + .sessions + .get(id) + .map(|session| session.view().active_view.clone()) + else { + return Vec::new(); + }; + let Some(mut answer) = self.close_download_prompt(id, "export") else { + return Vec::new(); + }; + match improvise_io::persistence::render_csv(&self.app.model_state.workbook, &view_name) { + Ok(bytes) => answer.push(Addressed { + to: id, + msg: ServerFrame::CsvBytes { filename, bytes }, + }), + Err(_) => answer.push(Addressed { + to: id, + msg: ServerFrame::Rejected(Rejection::ExportFailed), + }), + } + answer + } + + /// The browser folded these before sending the download request; mirror + /// that fold so a later projection cannot restore the prompt. + fn close_download_prompt( + &mut self, + id: SessionId, + buffer: &'static str, + ) -> Option> { + self.sessions.get(id)?; + Some(self.commands( + id, + &[ + Command::SetBuffer { + name: buffer.into(), + value: String::new(), + }, + Command::ChangeMode(AppMode::Normal), + ], + )) + } + /// Fan out whatever moved, for a host that changed the document outside /// `handle` — a worker's own persist is the caller (improvise-z754). pub fn project_all(&mut self) -> Vec { @@ -435,9 +498,12 @@ mod tests { } } } - // Neither answer carries state: one applied nothing, and what - // the other applied arrived as projections before it. - ServerFrame::Rejected(_) | ServerFrame::Accepted => {} + // These carry no foldable state: downloads are consumed by the + // browser, while other results arrived as projections or did nothing. + ServerFrame::Rejected(_) + | ServerFrame::Accepted + | ServerFrame::DocumentBytes { .. } + | ServerFrame::CsvBytes { .. } => {} } } grid @@ -1177,7 +1243,7 @@ mod tests { let mut host = host(); // Fill to MAX_CATEGORIES, so the frame's second command is refused by // `Model::add_category` — a DUPLICATE add is idempotent and vacuous. - for n in 0..11 { + for n in 0..crate::model::MAX_CATEGORIES - 1 { host.app_mut() .model_state .workbook @@ -1547,11 +1613,12 @@ mod tests { wb.model.set_cell(month_cell(month, measure), value); } } + wb.active_view_mut().set_expand_depth("Month", 1); let profit = crate::formula::parse_formula("Profit = Revenue - Cost", "_Measure"); wb.model.add_formula(profit.unwrap()); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); assert!( @@ -1733,13 +1800,14 @@ mod tests { for measure in ["Revenue", "Cost"] { wb.add_item_in_group("_Measure", measure, "Money").unwrap(); } + wb.active_view_mut().set_expand_depth("_Measure", 1); wb.add_item("_Measure", "Units").unwrap(); let jan = month_cell("Jan", "Revenue"); wb.model.set_cell(jan, CellValue::Number(10.0)); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut() - .toggle_group_collapse("_Measure", "Money"); + .toggle_group_collapse("_Measure", &["Money"]); let mut app = App::new(wb, None); app.rebuild_layout(); let target = month_cell("Feb", "Units"); @@ -1779,12 +1847,13 @@ mod tests { wb.model.set_cell(key, CellValue::Number(10.0)); } } + wb.active_view_mut().set_expand_depth("_Measure", 1); wb.add_item("_Measure", "Units").unwrap(); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); if collapsed { wb.active_view_mut() - .toggle_group_collapse("_Measure", "Money"); + .toggle_group_collapse("_Measure", &["Money"]); } let mut app = App::new(wb, None); app.rebuild_layout(); @@ -1808,6 +1877,7 @@ mod tests { for measure in ["Revenue", "Cost"] { wb.add_item_in_group("_Measure", measure, "Money").unwrap(); } + wb.active_view_mut().set_expand_depth("_Measure", 1); let measures = wb.model.category_mut("_Measure").unwrap(); measures.add_group(crate::model::category::Group::new("Money").with_parent("Totals")); wb.model @@ -2709,6 +2779,64 @@ mod tests { ); } + /// improvise-2mqz: view selection belongs to a session, so an export must + /// not silently use the document's default view or another client's view. + #[test] + fn a_csv_download_exports_the_requesting_sessions_active_view() { + let mut host = host(); + let second = host.app.model_state.workbook.create_view("Second"); + second.set_axis("Region", Axis::Row); + second.set_axis("_Measure", Axis::Column); + second.hide_item("Region", "South"); + let (requester, _) = host.connect(10, 10); + let (bystander, _) = host.connect(10, 10); + + host.handle( + requester, + ClientFrame::command(Command::SwitchView("Second".into())), + ); + assert_eq!( + host.sessions().get(requester).unwrap().view().active_view, + "Second", + "setup: the requester must be on its session-local view" + ); + assert_eq!( + host.sessions().get(bystander).unwrap().view().active_view, + "Default", + "setup: the second session must remain on the default view" + ); + assert_eq!( + host.app().model_state.workbook.active_view, + "Default", + "setup: the document's active view must differ from the requester" + ); + + let answer = host.handle( + requester, + ClientFrame::DownloadCsv { + filename: "second.csv".into(), + }, + ); + let expected = + improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Second") + .unwrap(); + let default = + improvise_io::persistence::render_csv(&host.app().model_state.workbook, "Default") + .unwrap(); + assert_ne!(expected, default, "setup: the two exports must differ"); + assert_eq!( + to(&answer, requester), + vec![ServerFrame::CsvBytes { + filename: "second.csv".into(), + bytes: expected, + }] + ); + assert!( + to(&answer, bystander).is_empty(), + "the download is owed only to its requester: {answer:?}" + ); + } + // ── disconnect ────────────────────────────────────────────────────── /// AC 4: a dropped client stops being projected to, and a late frame @@ -2979,7 +3107,8 @@ mod tests { let fresh = || { let mut wb = workbook(); wb.create_view("Scratch"); - (0..12).for_each(|n| drop(wb.add_category(format!("Filler{n}")))); + (0..crate::model::MAX_CATEGORIES) + .for_each(|n| drop(wb.add_category(format!("Filler{n}")))); let mut app = App::new(wb, None); app.storage = Box::new(super::import_fixture_storage()); app.rebuild_layout(); diff --git a/crates/improvise-command/src/ui/project.rs b/crates/improvise-command/src/ui/project.rs index 9327c27a..257c4b95 100644 --- a/crates/improvise-command/src/ui/project.rs +++ b/crates/improvise-command/src/ui/project.rs @@ -11,7 +11,7 @@ use improvise_core::model::record::{AggregateHash, RecordHash}; use improvise_core::view::layout::{AxisEntry, GridLayout, RecordCol}; use improvise_core::view::{Axis, View}; use improvise_core::workbook::Workbook; -use improvise_io::import::analyzer::{DateComponent, FieldKind}; +use improvise_io::import::analyzer::{DateComponent, DateShape, FieldKind}; use improvise_io::import::wizard::{DuplicatePolicy, ImportWizard, WizardStep}; use improvise_protocol::{ AxisFrame, Basis, BasisCache, CategoryRow, CategoryTreeCache, CategoryTreeUpdate, CellDisplay, @@ -25,7 +25,7 @@ use improvise_protocol::{ use crate::ui::app::App; use crate::ui::effect::{EffectKind, ModelChange}; use crate::ui::reduce::{Checked, apply_command}; -use crate::ui::viewport::{GridBody, grid_body}; +use crate::ui::viewport::{GridBody, grid_body, grid_window}; /// Which widgets a batch of effects could have changed. A gate, not a description: /// over-reporting costs work, under-reporting is a correctness bug (gotcha 35). @@ -142,13 +142,23 @@ pub fn grid_in(app: &App, view_name: &str, viewport: Viewport) -> GridCache { let plan = FormatPlan::of(model, view); let rows = viewport.rows_in(layout.row_count()); let cols = viewport.cols_in(layout.col_count()); - // ONE walk: the widths, the totals and this window's values all come off the - // same evaluation — `context/code/command.md` (improvise-jmjz). - let body = grid_body(model, layout, &plan, rows.clone(), cols.clone()); + // One walk gives widths, totals and window (improvise-jmjz); the host's layout + // keeps the first two until rebuilt, so its window costs its own cells. + let hosted = elsewhere.is_none() && derived.is_none(); + let body = match hosted { + true => grid_window( + model, + layout, + &app.grid_geometry().summary, + rows.clone(), + cols.clone(), + ), + false => grid_body(model, layout, &plan, rows.clone(), cols.clone()), + }; GridCache { - row_frame: axis_frame(&layout.row_cats, &layout.row_items, view, rows.clone()), - col_frame: axis_frame(&layout.col_cats, &layout.col_items, view, cols.clone()), + row_frame: axis_frame(&layout.row_cats, &layout.row_items, rows.clone()), + col_frame: axis_frame(&layout.col_cats, &layout.col_items, cols.clone()), col_widths: cols .clone() .map(|c| body.col_widths.get(c).copied().unwrap_or(0)) @@ -212,12 +222,7 @@ pub(crate) fn rederived_for( /// One axis's frame, windowed to the data indices `window` names. Three edge rules, /// all about group headers — see `context/code/command.md`. -fn axis_frame( - cats: &[String], - items: &[AxisEntry], - view: &View, - window: std::ops::Range, -) -> AxisFrame { +fn axis_frame(cats: &[String], items: &[AxisEntry], window: std::ops::Range) -> AxisFrame { let total = items .iter() .filter(|e| matches!(e, AxisEntry::DataItem { .. })) @@ -225,40 +230,48 @@ fn axis_frame( let mut data_idx = 0usize; let mut entries: Vec = Vec::new(); let mut pending: Vec = Vec::new(); - // Before the window opens, only the runs still open matter, one per level: - // O(levels), however many groups a deep scroll passed (improvise-e265). - let mut carried: Vec> = Vec::new(); + // Before the window opens, only the runs still open matter, keyed `(level, + // depth)`: O(depth), however many groups a deep scroll passed (e265, felo.6). + let mut carried: Vec<((usize, usize), FrameEntry)> = Vec::new(); + let close = |carried: &mut Vec<((usize, usize), FrameEntry)>, key: (usize, usize)| { + carried.truncate(carried.partition_point(|(k, _)| *k < key)) + }; for entry in items { match entry { AxisEntry::GroupHeader { level, - cat_name, + depth, group_name, + collapsed, + .. } => { let group = FrameEntry::Group(FrameGroup { level: *level, + depth: *depth, name: group_name.clone(), - collapsed: view.is_group_collapsed(cat_name, group_name), + collapsed: *collapsed, }); match entries.is_empty() { true => { - carried.truncate(*level); - carried.resize(*level, None); - carried.push(Some(group)); + close(&mut carried, (*level, *depth)); + carried.push(((*level, *depth), group)); } false => pending.push(group), } } - AxisEntry::GroupEnd { level } => match entries.is_empty() { - true => carried.truncate(*level), - false => pending.push(FrameEntry::GroupEnd { level: *level }), + AxisEntry::GroupEnd { level, depth } => match entries.is_empty() { + true => close(&mut carried, (*level, *depth)), + false => pending.push(FrameEntry::GroupEnd { + level: *level, + depth: *depth, + }), }, AxisEntry::DataItem { tuple, collapsed } => { let idx = data_idx; data_idx += 1; if window.contains(&idx) { if entries.is_empty() { - entries.extend(carried.drain(..).flatten()); + entries.extend(carried.drain(..).map(|(_, group)| group)); } entries.append(&mut pending); entries.push(FrameEntry::Item { @@ -271,7 +284,7 @@ fn axis_frame( } if window.end >= total { if entries.is_empty() { - entries.extend(carried.drain(..).flatten()); + entries.extend(carried.drain(..).map(|(_, group)| group)); } entries.append(&mut pending); } @@ -423,6 +436,11 @@ fn non_item(category: &str, item: &str) -> CellId { } } +/// What a person sees for a category key; the key itself when no category holds it. +fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str { + model.category(name).map_or(name, |c| c.display()) +} + /// The categories the category panel draws: **every** category, virtual ones /// included, with their items and their axis (improvise-vjms, gotcha 7). pub fn category_rows(model: &Model, view: &View) -> Vec { @@ -431,6 +449,7 @@ pub fn category_rows(model: &Model, view: &View) -> Vec { .into_iter() .map(|name| CategoryRow { name: name.to_string(), + display: category_display(model, name).to_string(), items: model.effective_item_names(name), // `try_axis_of`, not `axis_of`: a view that has never seen a category // has no entry and `axis_of` panics. Unseen shows as `None`. @@ -460,6 +479,7 @@ pub fn view_summaries(app: &App) -> Vec { .iter() .map(|(name, view)| ViewSummary { name: name.clone(), + display: view.display().to_string(), axes: view .category_axes .iter() @@ -481,6 +501,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec { .into_iter() .map(|cat| TileEntry { category: cat.to_string(), + display: category_display(model, cat).to_string(), axis: view.try_axis_of(cat).unwrap_or(Axis::None), kind: model .category(cat) @@ -641,6 +662,7 @@ pub fn wizard_cache(w: &ImportWizard) -> WizardCache { ((*label).to_string(), p.date_components.contains(component)) }) .collect(), + shape: date_shape_sentence(p), }) .collect(), collisions: w @@ -679,6 +701,27 @@ pub fn wizard_cache(w: &ImportWizard) -> WizardCache { } } +/// What a date field's chosen components become, worded for every front end. +fn date_shape_sentence(p: &improvise_io::import::analyzer::FieldProposal) -> String { + let chosen = |order: &[DateComponent]| -> Vec<&str> { + order + .iter() + .filter(|c| p.date_components.contains(c)) + .filter_map(|c| DATE_COMPONENTS.iter().find(|(d, _)| d == c)) + .map(|(_, label)| *label) + .collect() + }; + match (p.date_shape, chosen(&DateComponent::COARSE_TO_FINE)) { + (_, none) if none.is_empty() => "one category of the raw dates".to_string(), + (DateShape::Hierarchy, levels) => format!("one category: {}", levels.join(" > ")), + (DateShape::Split, _) => { + let parts = chosen(&DATE_COMPONENTS.map(|(c, _)| c)); + let names: Vec = parts.iter().map(|l| format!("{}_{l}", p.field)).collect(); + format!("split: {}, {}", p.field, names.join(", ")) + } + } +} + /// The date components the wizard offers, **in the order it offers them**: /// `ImportWizard::date_config_at_cursor` counts these same rows. const DATE_COMPONENTS: [(DateComponent, &str); 3] = [ @@ -1458,6 +1501,7 @@ mod tests { wb.add_item("Region", "North").unwrap(); wb.add_item("Region", "South").unwrap(); wb.add_item_in_group("Region", "East", "Late").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -1469,7 +1513,8 @@ mod tests { wb.model.set_cell(key, CellValue::Number(1.0)); } if collapsed { - wb.active_view_mut().toggle_group_collapse("Region", "Late"); + wb.active_view_mut() + .toggle_group_collapse("Region", &["Late"]); } let mut app = App::new(wb, None); app.rebuild_layout(); @@ -1573,6 +1618,7 @@ mod tests { wb.add_item("Region", "North").unwrap(); wb.add_item_in_group("Region", "Inner", "Hidden").unwrap(); wb.add_item_in_group("Region", "East", "Late").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -1584,7 +1630,7 @@ mod tests { wb.model.set_cell(key, CellValue::Number(1.0)); } wb.active_view_mut() - .toggle_group_collapse("Region", "Hidden"); + .toggle_group_collapse("Region", &["Hidden"]); let mut app = App::new(wb, None); app.rebuild_layout(); app @@ -1626,9 +1672,11 @@ mod tests { wb.add_category("Region").unwrap(); wb.add_category("Month").unwrap(); wb.add_item_in_group("Region", "North", "Upper").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("Region", "South").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Month", "Mar").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); @@ -1674,6 +1722,52 @@ mod tests { ); } + /// improvise-felo.6: the same agreement for runs nested INSIDE one category, + /// at every window — the carried stack is keyed by depth as well as level. + #[test] + fn the_frame_agrees_with_the_layout_inside_one_categorys_hierarchy() { + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_category("Date").unwrap(); + wb.add_item_in_group("Region", "North", "Upper").unwrap(); + wb.add_item("Region", "South").unwrap(); + for (month, group) in [("Jan", "Q1"), ("Feb", "Q1"), ("Jun", "H1"), ("Jul", "Q3")] { + wb.add_item_in_group("Date", month, group).unwrap(); + } + wb.add_item("Date", "Undated").unwrap(); + let date = wb.model.category_mut("Date").unwrap(); + date.add_group(crate::model::category::Group::new("Q1").with_parent("H1")); + date.add_group(crate::model::category::Group::new("Q3").with_parent("H2")); + wb.add_item("_Measure", "Revenue").unwrap(); + let view = wb.active_view_mut(); + view.set_axis("Region", Axis::Row); + view.set_axis("Date", Axis::Row); + view.set_axis("_Measure", Axis::Column); + view.set_expand_depth("Region", 1); + view.set_expand_depth("Date", 2); + let mut app = App::new(wb, None); + app.rebuild_layout(); + let rows = app.layout.row_count(); + assert_eq!(rows, 10, "two regions by five dates"); + for offset in 0..rows { + let frame = grid_of(&app, rows_from(offset)).row_frame; + let from_frame: Vec> = frame + .groups() + .into_iter() + .map(|g| g.map(|g| (frame.cats[g.level].clone(), g.name.clone()))) + .collect(); + let from_layout: Vec> = (offset..rows) + .map(|r| app.layout.row_group_for(r)) + .collect(); + assert_eq!(from_frame, from_layout, "window from row {offset}"); + } + assert_eq!( + app.layout.row_group_for(2), + Some(("Date".into(), "H1".into())), + "precondition: North/Jun sits in H1 once Q1 ends, not in Q1 nor Upper" + ); + } + /// A window opening inside `North/Q1` carries the open run at EVERY level, or /// `North/Mar` loses `Upper` once `Q1` ends. context/test-evidence/command.md. #[test] @@ -1693,7 +1787,7 @@ mod tests { fn z_on_a_row_past_an_inner_run_folds_the_outer_group() { use crate::command::key::{KeyCode, KeyEvent, KeyModifiers}; let z = || KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE); - let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, group); + let collapsed = |app: &App, cat, group| app.view().is_group_collapsed(cat, &[group]); let mut app = app_with_nested_groups(); app.cursor_mut().selected = (0, 0); @@ -1739,6 +1833,46 @@ mod tests { app } + /// One category and the active view each shown under a display that differs from their key. + fn app_with_displays_unlike_their_keys() -> App { + let mut app = app_with_data(); + let wb = &mut app.model_state.workbook; + wb.model + .category_mut("Region") + .unwrap() + .set_display("Sales Area"); + wb.active_view_mut().set_display("Overview"); + app + } + + #[test] + fn the_category_panel_shows_a_display_and_keeps_the_key_commands_name() { + let app = app_with_displays_unlike_their_keys(); + let rows = category_rows(&app.model_state.workbook.model, app.view()); + let region = rows.iter().find(|r| r.name == "Region").unwrap(); + assert_eq!(region.display, "Sales Area"); + let measure = rows.iter().find(|r| r.name == "_Measure").unwrap(); + assert_eq!(measure.display, "_Measure"); + } + + #[test] + fn a_tile_shows_a_display_and_keeps_the_key_commands_name() { + let app = app_with_displays_unlike_their_keys(); + let tiles = tiles_of(&app.model_state.workbook.model, app.view()); + let region = tiles.iter().find(|t| t.category == "Region").unwrap(); + assert_eq!(region.display, "Sales Area"); + } + + #[test] + fn the_view_list_shows_a_display_and_keeps_the_key_commands_name() { + let app = app_with_displays_unlike_their_keys(); + let active = app.model_state.workbook.active_view.clone(); + let summaries = view_summaries(&app); + let summary = summaries.iter().find(|s| s.name == active).unwrap(); + assert_eq!(summary.display, "Overview"); + assert_ne!(summary.name, "Overview"); + } + /// A pivot-shaped default view plus a records-shaped second one, so a /// switch between them crosses the boundary `settled_mode` watches. fn app_with_a_records_shaped_second_view() -> App { @@ -2895,6 +3029,7 @@ mod tests { .unwrap(); wb.add_item_in_group("Product", "Gadget", "Hardware") .unwrap(); + wb.active_view_mut().set_expand_depth("Product", 1); wb.add_item("Product", "Service").unwrap(); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); @@ -3698,7 +3833,7 @@ mod tests { item: "Gizmo".into(), group: "Hardware".into(), }, - // The 12-category limit; the fixture already holds two. + // The category limit; the fixture already holds two. ModelCommand::AddCategory("Overflow".into()), ]; @@ -4658,6 +4793,7 @@ mod cell_identity { wb.add_item("Region", "North").unwrap(); wb.add_item_in_group("Region", "East", "Coastal").unwrap(); wb.add_item_in_group("Region", "West", "Coastal").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -4671,7 +4807,7 @@ mod cell_identity { ); } wb.active_view_mut() - .toggle_group_collapse("Region", "Coastal"); + .toggle_group_collapse("Region", &["Coastal"]); let mut app = App::new(wb, None); app.rebuild_layout(); assert_eq!(app.layout.row_label(1), "Coastal"); @@ -5642,9 +5778,11 @@ mod cell_identity { let mut wb = Workbook::new("T"); wb.add_category("Month").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); for month in ["Jan", "Feb", "Mar"] { wb.add_item_in_group("Month", month, "Q1").unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0), ("Mar", 50.0, 50.0)] { for (measure, n) in [("Revenue", revenue), ("Cost", cost)] { @@ -5663,7 +5801,7 @@ mod cell_identity { wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().hide_item("Month", "Mar"); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let app = App::new(wb, None); let grid = grid_of(&app, WHOLE); @@ -6334,13 +6472,14 @@ mod column_totals { wb.model.set_cell(at, CellValue::Number(v)); } } + wb.active_view_mut().set_expand_depth("Month", 1); let max = crate::formula::AggFunc::Max; wb.model.measure_agg.insert("Revenue".into(), max); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut().set_axis("Region", Axis::None); if collapsed { - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); } wb }; @@ -6405,3 +6544,42 @@ mod column_totals { ); } } + +/// improvise-8jk6.3: the wizard's date step says what each date field becomes, +/// and `s` switches it between one category and the split. +#[cfg(test)] +mod date_shape { + use super::wizard_cache; + use crate::import::wizard::{ImportWizard, WizardStep}; + use serde_json::json; + + #[test] + fn the_date_step_says_what_a_date_field_becomes_and_s_switches_it() { + let mut w = ImportWizard::new(json!([ + {"Region": "East", "When": "2024-01-15", "Revenue": 1.0}, + {"Region": "West", "When": "2024-04-02", "Revenue": 2.0}, + ])); + w.advance(); + assert_eq!(w.step, WizardStep::ConfigureDates, "setup"); + let shape = |w: &ImportWizard| wizard_cache(w).date_fields[0].shape.clone(); + assert_eq!(shape(&w), "one category of the raw dates"); + w.toggle_date_component(); + w.move_cursor(1); + w.toggle_date_component(); + w.move_cursor(1); + w.toggle_date_component(); + assert_eq!(shape(&w), "one category: Year > Quarter > Month"); + w.toggle_date_shape(); + assert_eq!( + shape(&w), + "split: When, When_Year, When_Month, When_Quarter" + ); + let built = w.pipeline.build_model().unwrap(); + assert!(built.model.category("When_Quarter").is_some()); + w.toggle_date_shape(); + let built = w.pipeline.build_model().unwrap(); + assert!(built.model.category("When_Quarter").is_none()); + let when = built.model.category("When").unwrap(); + assert_eq!(when.item_chain("2024-04"), vec!["2024", "2024-Q2"]); + } +} diff --git a/crates/improvise-command/src/ui/reduce.rs b/crates/improvise-command/src/ui/reduce.rs index f1b41d66..605f22f8 100644 --- a/crates/improvise-command/src/ui/reduce.rs +++ b/crates/improvise-command/src/ui/reduce.rs @@ -1052,9 +1052,9 @@ pub(crate) mod tests { view.hide_item("Region", "West"); view.hide_item("Region", "South"); view.hide_item("Month", "Mar"); - view.toggle_group_collapse("Region", "Coastal"); - view.toggle_group_collapse("Region", "Inland"); - view.toggle_group_collapse("Month", "Q1"); + view.toggle_group_collapse("Region", &["Coastal"]); + view.toggle_group_collapse("Region", &["Inland"]); + view.toggle_group_collapse("Month", &["Q1"]); app.rebuild_layout(); app } diff --git a/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs b/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs index 1f8fa1f8..8853ff19 100644 --- a/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs +++ b/crates/improvise-command/src/ui/reduce/vocabulary_is_complete.rs @@ -16,8 +16,6 @@ enum Verdict { Frame(&'static str), /// Deliberately off the wire, and the argument. Off(&'static str), - /// Missing, and the ticket that owns closing it. - Gap(&'static str), } /// Checked in all directions: an entry must name a real effect, must not be on @@ -29,14 +27,8 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[ // Its text; the refusal flag is one host batch's outcome (context/code/cli.md §10). ("Refuse", Verdict::Command("SetStatus")), ("Save", Verdict::Frame("Save")), - ( - "SaveAs", - Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"), - ), - ( - "ExportCsv", - Verdict::Gap("improvise-2mqz: writes the host's Storage and has no frame"), - ), + ("SaveAs", Verdict::Frame("DownloadDocument")), + ("ExportCsv", Verdict::Frame("DownloadCsv")), ( "ApplyAndClearDrill", Verdict::Off("operand is one session's staged drill edits (gotcha 38, improvise-3x3u)"), @@ -71,6 +63,10 @@ const NOT_ON_THE_WIRE: &[(&str, Verdict)] = &[ "refreshes the host's layout cache mid-batch; a client holds none (gotcha 23)", ), ), + ( + "RequireKnown", + Verdict::Off("a command-side name check that writes no state (gotcha 21, cli.md §15)"), + ), ( "AbortChain", Verdict::Off("control flow inside one apply_effects batch; it writes no state (gotcha 21)"), @@ -134,7 +130,7 @@ fn the_verdict_list_names_only_real_effects_and_real_variants() { frames.iter().any(|f| f == variant), "{name}'s verdict names ClientFrame::{variant}, which does not exist" ), - Verdict::Off(why) | Verdict::Gap(why) => { + Verdict::Off(why) => { assert!(!why.is_empty(), "{name}'s verdict gives no argument") } } diff --git a/crates/improvise-command/src/ui/viewport.rs b/crates/improvise-command/src/ui/viewport.rs index 32905e8e..01d499b0 100644 --- a/crates/improvise-command/src/ui/viewport.rs +++ b/crates/improvise-command/src/ui/viewport.rs @@ -101,6 +101,50 @@ pub fn grid_body( } } +/// What of a grid build no window changes: the widths, each column's total +/// format and the totals. Measured over every cell, so a host keeps one per layout. +pub struct GridSummary { + pub col_widths: Vec, + pub col_formats: Vec, + pub totals: Vec>, +} + +/// The whole-grid walk, answering only what a window cannot. +pub fn grid_summary(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> GridSummary { + let body = grid_body(model, layout, plan, 0..0, 0..0); + GridSummary { + col_widths: body.col_widths, + col_formats: body.col_formats, + totals: body.totals, + } +} + +/// [`grid_body`] for a window, given the `summary` of the same layout: evaluates +/// only the window's cells. Equal to `grid_body` where `summary` is current. +pub fn grid_window( + model: &Model, + layout: &GridLayout, + summary: &GridSummary, + rows: std::ops::Range, + cols: std::ops::Range, +) -> GridBody { + let eval = layout.evaluator(model); + let records = layout.is_records_mode(); + let window = rows + .map(|ri| { + cols.clone() + .map(|ci| cell_value(layout, &eval, records, ri, ci).1) + .collect() + }) + .collect(); + GridBody { + col_widths: summary.col_widths.clone(), + col_formats: summary.col_formats.clone(), + totals: summary.totals.clone(), + window, + } +} + /// The one evaluation. Records mode reads its wide row; pivot mode aggregates. fn cell_value( layout: &GridLayout, @@ -146,7 +190,7 @@ fn widen(widths: &mut [u16], col: usize, width: usize) { /// Adaptive column widths alone, for the keypress path, which needs no cells. /// It is [`grid_body`] with an empty window so there is one width rule. pub fn compute_col_widths(model: &Model, layout: &GridLayout, plan: &FormatPlan) -> Vec { - grid_body(model, layout, plan, 0..0, 0..0).col_widths + grid_summary(model, layout, plan).col_widths } /// The total row-header width for a layout. The rule is the protocol's; this is the @@ -214,6 +258,7 @@ mod tests { wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); for (month, revenue) in [("Jan", 10.0), ("Feb", 30.0)] { let key = CellKey::new(vec![ @@ -231,7 +276,7 @@ mod tests { } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); wb } @@ -271,4 +316,33 @@ mod tests { "F0..F8 once for the Q1 row, once for the totals" ); } + + /// improvise-felo.13: a window read off a held summary is the window `grid_body` + /// builds. Evidence: `context/test-evidence/layout-cost.md`. + #[test] + fn a_window_over_a_held_summary_equals_the_one_walk_grid_body() { + let wb = folded_chain(); + let view = wb.active_view(); + let layout = GridLayout::new(&wb.model, view); + let plan = FormatPlan::of(&wb.model, view); + let summary = grid_summary(&wb.model, &layout, &plan); + let (rows, cols) = (layout.row_count(), layout.col_count()); + assert!( + rows >= 2 && cols >= 3, + "a window strictly inside is possible" + ); + for (r, c) in [ + (0..rows, 0..cols), + (1..2, 1..3), + (rows - 1..rows, 0..1), + (0..0, 0..0), + ] { + let walked = grid_body(&wb.model, &layout, &plan, r.clone(), c.clone()); + let held = grid_window(&wb.model, &layout, &summary, r.clone(), c.clone()); + assert_eq!(held.window, walked.window, "window {r:?} x {c:?}"); + assert_eq!(held.col_widths, walked.col_widths); + assert_eq!(held.totals, walked.totals); + assert_eq!(held.col_formats, walked.col_formats); + } + } } diff --git a/crates/improvise-core/Cargo.toml b/crates/improvise-core/Cargo.toml index 383fb843..a83cf35b 100644 --- a/crates/improvise-core/Cargo.toml +++ b/crates/improvise-core/Cargo.toml @@ -16,6 +16,8 @@ serde = { version = "1", features = ["derive"] } # (ADR 002). Already in the tree as a transitive dependency; no_std and # wasm-clean. siphasher = "1" +# Unicode full case folding for slugify (ADR 007 answer 26). no_std, no dependencies. +unicase = "2.9" [features] # Exposes the fold-evaluation counter to other crates' tests (improvise-0jw5). diff --git a/crates/improvise-core/examples/category_width_bench.rs b/crates/improvise-core/examples/category_width_bench.rs new file mode 100644 index 00000000..9d9d720f --- /dev/null +++ b/crates/improvise-core/examples/category_width_bench.rs @@ -0,0 +1,128 @@ +//! What a wider document costs: the same records at 12 and at 24 regular +//! categories. improvise-felo.1; the argument is `context/code/core.md` §9. + +use std::time::Instant; + +use improvise_core::model::cell::{CellKey, CellValue}; +use improvise_core::view::{Axis, GridLayout, ViewStep, records_steps}; +use improvise_core::workbook::Workbook; + +const MEASURE: &str = "_Measure"; +const ITEMS: u64 = 4; + +/// `records` records over `width` categories of `ITEMS` items, two measures +/// each; coordinates from a fixed LCG so both widths see comparable data. +fn document(width: usize, records: usize) -> Workbook { + let mut wb = Workbook::new("bench"); + for c in 0..width { + wb.add_category(format!("C{c}")).expect("under the limit"); + } + let mut state: u64 = 0x9E37_79B9_7F4A_7C15; + let mut next = move || { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (state >> 33) % ITEMS + }; + for _ in 0..records { + let coords: Vec<(String, String)> = (0..width) + .map(|c| (format!("C{c}"), format!("i{}", next()))) + .collect(); + for (name, value) in [("Cost", 3.0), ("Rev", 5.0)] { + let mut key = coords.clone(); + key.push((MEASURE.to_string(), name.to_string())); + wb.model + .set_cell(CellKey::new(key), CellValue::Number(value)); + } + } + for c in 0..width { + let cat = wb.model.category_mut(&format!("C{c}")).expect("added"); + for i in 0..ITEMS { + cat.add_item(format!("i{i}")); + } + } + for name in ["Cost", "Rev"] { + wb.model + .category_mut(MEASURE) + .expect("virtual") + .add_item(name); + } + wb +} + +/// Best of `runs`, in milliseconds. +fn best(runs: usize, mut f: impl FnMut()) -> f64 { + (0..runs) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .fold(f64::INFINITY, f64::min) +} + +fn pivot(wb: &mut Workbook, width: usize) -> f64 { + let view = wb.active_view_mut(); + for c in 0..width { + view.set_axis( + &format!("C{c}"), + if c == 0 { Axis::Row } else { Axis::None }, + ); + } + view.set_axis(MEASURE, Axis::Column); + let (model, view) = (&wb.model, wb.active_view()); + best(5, || { + let layout = GridLayout::with_frozen_records(model, view, None); + let eval = layout.evaluator(model); + for r in 0..layout.row_count() { + for c in 0..layout.col_count() { + let key = layout.cell_key(r, c).expect("in bounds"); + std::hint::black_box(eval.cell(&key)); + } + } + }) +} + +/// (build, draw): the records layout, then every cell's display text. +fn records(wb: &mut Workbook) -> (f64, f64) { + let steps = records_steps(&wb.model); + let view = wb.active_view_mut(); + for step in steps { + if let ViewStep::Axis { category, axis } = step { + view.set_axis(&category, axis); + } + } + let (model, view) = (&wb.model, wb.active_view()); + let build = best(5, || { + std::hint::black_box(GridLayout::with_frozen_records(model, view, None)); + }); + let layout = GridLayout::with_frozen_records(model, view, None); + let draw = best(5, || { + for r in 0..layout.row_count() { + for c in 0..layout.col_count() { + std::hint::black_box(layout.records_display(r, c)); + } + } + }); + (build, draw) +} + +fn main() { + println!( + "{:>6} {:>8} {:>7} {:>9} {:>9} {:>14} {:>13}", + "width", "records", "cells", "store ms", "pivot ms", "rec build ms", "rec draw ms" + ); + for records_n in [2_500, 5_000] { + for width in [12, 24] { + let mut built = None; + let store = best(3, || built = Some(document(width, records_n))); + let mut wb = built.expect("ran"); + let cells = wb.model.data.iter_cells().count(); + let p = pivot(&mut wb, width); + let (rb, rd) = records(&mut wb); + println!( + "{width:>6} {records_n:>8} {cells:>7} {store:>9.1} {p:>9.1} {rb:>14.1} {rd:>13.1}" + ); + } + } +} diff --git a/crates/improvise-core/src/model/category.rs b/crates/improvise-core/src/model/category.rs index 31d881c6..ac08ae37 100644 --- a/crates/improvise-core/src/model/category.rs +++ b/crates/improvise-core/src/model/category.rs @@ -8,19 +8,27 @@ pub type ItemId = usize; pub struct Item { pub id: ItemId, pub name: String, + /// What a person sees; the key until a rename (ADR 007 clause 2). + display: String, /// Parent group name, if any pub group: Option, } impl Item { pub fn new(id: ItemId, name: impl Into) -> Self { + let name = name.into(); Self { id, - name: name.into(), + display: name.clone(), + name, group: None, } } + pub fn display(&self) -> &str { + &self.display + } + pub fn with_group(mut self, group: impl Into) -> Self { self.group = Some(group.into()); self @@ -129,6 +137,8 @@ impl CategoryKind { pub struct Category { pub id: CategoryId, pub name: String, + /// What a person sees; the key until a rename (ADR 007 clause 2). + display: String, /// Items in insertion order pub items: IndexMap, /// Named groups (hierarchy nodes) @@ -141,9 +151,11 @@ pub struct Category { impl Category { pub fn new(id: CategoryId, name: impl Into) -> Self { + let name = name.into(); Self { id, - name: name.into(), + display: name.clone(), + name, items: IndexMap::new(), groups: Vec::new(), next_item_id: 0, @@ -151,6 +163,23 @@ impl Category { } } + pub fn display(&self) -> &str { + &self.display + } + + /// Changes what a person sees and nothing else: the key does not move. + pub fn set_display(&mut self, display: impl Into) { + self.display = display.into(); + } + + /// Changes an item's display; false when the category has no such item. + pub fn set_item_display(&mut self, item: &str, display: impl Into) -> bool { + self.items + .get_mut(item) + .map(|i| i.display = display.into()) + .is_some() + } + pub fn with_kind(mut self, kind: CategoryKind) -> Self { self.kind = kind; self @@ -199,15 +228,61 @@ impl Category { self.items.keys().map(|s| s.as_str()).collect() } - /// The items whose own `group` names `group`, in insertion order. Read off the - /// items, never `groups`: `add_item_in_group` registers no `Group` (core.md §14). + /// The items `group` covers — its own and every child group's, through any + /// depth — in insertion order. Read off the items, not `groups` (core.md §14). pub fn items_in_group(&self, group: &str) -> Vec<&str> { self.items .values() - .filter(|i| i.group.as_deref() == Some(group)) + .filter(|i| self.item_chain(&i.name).contains(&group)) .map(|i| i.name.as_str()) .collect() } + + /// The parent a `>` line gave `group`; `None` for a root. + pub fn group_parent(&self, group: &str) -> Option<&str> { + self.groups + .iter() + .find(|g| g.name == group) + .and_then(|g| g.parent.as_deref()) + } + + /// `group` and its ancestors, ROOT FIRST. A cycle built in code stops the walk + /// where it would repeat; a file cannot carry one (`hierarchy_cycle`). + pub fn group_chain<'a>(&'a self, group: &'a str) -> Vec<&'a str> { + let mut chain = vec![group]; + while let Some(parent) = chain.last().and_then(|g| self.group_parent(g)) { + if chain.contains(&parent) { + break; + } + chain.push(parent); + } + chain.reverse(); + chain + } + + /// The groups an item sits in, root first; empty for an ungrouped item. + pub fn item_chain(&self, item: &str) -> Vec<&str> { + self.items + .get(item) + .and_then(|i| i.group.as_deref()) + .map(|g| self.group_chain(g)) + .unwrap_or_default() + } + + /// A cycle among the declared parents, its members in walk order: the one + /// shape a hierarchy can take that is not a forest (core.md §19). + pub fn hierarchy_cycle(&self) -> Option> { + self.groups.iter().find_map(|start| { + let mut path: Vec<&str> = vec![start.name.as_str()]; + while let Some(parent) = path.last().and_then(|g| self.group_parent(g)) { + if let Some(at) = path.iter().position(|g| *g == parent) { + return Some(path[at..].to_vec()); + } + path.push(parent); + } + None + }) + } } #[cfg(test)] @@ -218,6 +293,20 @@ mod tests { Category::new(0, "Region") } + #[test] + fn a_display_starts_as_the_key_and_changing_it_moves_no_key() { + let mut c = cat(); + c.add_item("East"); + assert_eq!(c.display(), "Region"); + assert_eq!(c.items["East"].display(), "East"); + c.set_display("Sales Area"); + assert!(c.set_item_display("East", "Eastern")); + assert_eq!((c.name.as_str(), c.display()), ("Region", "Sales Area")); + assert_eq!(c.items["East"].display(), "Eastern"); + assert_eq!(c.items["East"].name, "East"); + assert!(!c.set_item_display("West", "Western"), "no such item"); + } + /// improvise-2lh: call sites ask `of_name` rather than compare literals, /// so both directions of the mapping must agree. #[test] @@ -297,6 +386,66 @@ mod tests { assert_eq!(c.groups.len(), 1); } + /// Year > Quarter > Month, with `2024-03` added LAST so Q1-2024 and 2024 are + /// non-contiguous in insertion order. + fn dates() -> Category { + let mut c = Category::new(0, "Date"); + c.add_item_in_group("2024-01", "Q1-2024"); + c.add_item_in_group("2024-04", "Q2-2024"); + c.add_item_in_group("2025-01", "Q1-2025"); + c.add_item("Undated"); + c.add_item_in_group("2024-03", "Q1-2024"); + c.add_group(Group::new("Q1-2024").with_parent("2024")); + c.add_group(Group::new("Q2-2024").with_parent("2024")); + c.add_group(Group::new("Q1-2025").with_parent("2025")); + c + } + + #[test] + fn a_parent_group_covers_every_leaf_beneath_its_child_groups() { + let c = dates(); + assert_eq!( + c.items_in_group("2024"), + vec!["2024-01", "2024-04", "2024-03"] + ); + assert_eq!(c.items_in_group("Q1-2024"), vec!["2024-01", "2024-03"]); + assert_eq!(c.items_in_group("2025"), vec!["2025-01"]); + assert!(c.items_in_group("Undated").is_empty()); + } + + #[test] + fn an_items_chain_runs_root_first() { + let c = dates(); + assert_eq!(c.item_chain("2024-03"), vec!["2024", "Q1-2024"]); + assert!(c.item_chain("Undated").is_empty()); + assert_eq!(c.group_chain("2024"), vec!["2024"]); + assert_eq!(c.group_chain("Q2-2024"), vec!["2024", "Q2-2024"]); + } + + #[test] + fn a_cycle_among_parents_is_found_and_named() { + let mut c = dates(); + assert_eq!(c.hierarchy_cycle(), None); + c.add_group(Group::new("2024").with_parent("Q2-2024")); + let cycle = c.hierarchy_cycle().expect("2024 -> Q2-2024 -> 2024"); + assert!( + cycle.contains(&"2024") && cycle.contains(&"Q2-2024"), + "{cycle:?}" + ); + assert!(!cycle.contains(&"Q1-2025"), "{cycle:?}"); + assert!( + c.group_chain("Q1-2024").len() <= 3, + "a walk through a cycle stops" + ); + } + + #[test] + fn a_self_parent_is_a_cycle_of_one() { + let mut c = cat(); + c.add_group(Group::new("G").with_parent("G")); + assert_eq!(c.hierarchy_cycle(), Some(vec!["G"])); + } + #[test] fn item_index_reflects_insertion_order() { let mut c = cat(); diff --git a/crates/improvise-core/src/model/cell.rs b/crates/improvise-core/src/model/cell.rs index dbae8faa..ab23eb70 100644 --- a/crates/improvise-core/src/model/cell.rs +++ b/crates/improvise-core/src/model/cell.rs @@ -194,6 +194,12 @@ impl RecordEntry { } } +#[cfg(test)] +thread_local! { + /// How many lookups fell through to set narrowing, for the exact-path test. + static NARROWED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[derive(Clone, Default)] pub struct DataStore { /// Primary storage: interned keys, insertion-ordered so records mode can @@ -206,6 +212,9 @@ pub struct DataStore { /// Reverse index for the client-facing surrogate (ADR 002), plus each /// record's write counter. Why it lives here: core.md §8. records: HashMap, + /// How many stored cells have each number of coordinates; its largest key + /// says when a partial can match only one exact cell. + arities: std::collections::BTreeMap, } /// Resolved through the symbol table and sorted wherever the storage is a @@ -217,6 +226,7 @@ impl std::fmt::Debug for DataStore { symbols, index, records, + arities: _, } = self; let cells: Vec<_> = cells .iter() @@ -291,7 +301,11 @@ impl DataStore { for pair in &ikey.0 { self.index.entry(*pair).or_default().insert(ikey.clone()); } + let width = ikey.0.len(); let is_new_cell = self.cells.insert(ikey, value).is_none(); + if is_new_cell { + *self.arities.entry(width).or_default() += 1; + } let entry = self.records.entry(hash).or_insert(RecordEntry { record, cells: 0, @@ -327,6 +341,12 @@ impl DataStore { return; }; if self.cells.shift_remove(&ikey).is_some() { + if let Some(n) = self.arities.get_mut(&ikey.0.len()) { + *n -= 1; + if *n == 0 { + self.arities.remove(&ikey.0.len()); + } + } for pair in &ikey.0 { if let Some(set) = self.index.get_mut(pair) { set.remove(&ikey); @@ -378,11 +398,28 @@ impl DataStore { /// Positions in `cells` of every cell matching `partial`, **in insertion /// order**. The index narrows; the primary map orders (§8). fn matching_positions(&self, partial: &[(String, String)]) -> Vec { + if let Some(exact) = self.exact_positions(partial) { + return exact; + } self.intern_partial(partial) .map(|pairs| self.positions_of(&pairs)) .unwrap_or_default() } + /// The answer without narrowing, when `partial` is as wide as the widest cell + /// stored: only an equal cell can match (layout-cost.md). `None` otherwise. + fn exact_positions(&self, partial: &[(String, String)]) -> Option> { + let widest = *self.arities.keys().next_back()?; + // Strictly sorted by category, the order a stored key is in, so one probe is the lookup. + let canonical = partial.windows(2).all(|w| w[0].0 < w[1].0); + (!partial.is_empty() && widest <= partial.len() && canonical).then(|| { + let found = self + .intern_partial(partial) + .and_then(|pairs| self.cells.get_index_of(&InternedKey(pairs))); + found.into_iter().collect() + }) + } + /// `partial` as symbols, or `None` when a name was never interned, which /// no stored cell can then match. fn intern_partial(&self, partial: &[(String, String)]) -> Option> { @@ -412,6 +449,8 @@ impl DataStore { } sets.sort_by_key(|s| s.len()); + #[cfg(test)] + NARROWED.with(|n| n.set(n.get() + 1)); let mut positions: Vec = match sets.split_first() { Some((first, rest)) => first @@ -1356,3 +1395,105 @@ mod record_index { } } } + +#[cfg(test)] +mod exact_path_tests { + use super::{CellKey, CellValue, DataStore, NARROWED}; + + fn key(a: &str, b: &str, c: &str) -> CellKey { + CellKey::new(vec![ + ("A".into(), a.into()), + ("B".into(), b.into()), + ("C".into(), c.into()), + ]) + } + + fn store() -> DataStore { + let mut d = DataStore::new(); + for i in 0..6 { + for j in 0..6 { + d.set( + key(&format!("a{i}"), &format!("b{j}"), "c"), + CellValue::Number((i * 6 + j) as f64), + ); + } + } + d + } + + fn narrowings(f: impl FnOnce()) -> usize { + NARROWED.with(|n| n.set(0)); + f(); + NARROWED.with(|n| n.get()) + } + + /// improvise-felo.13: context/test-evidence/layout-cost.md. + #[test] + fn a_full_width_partial_probes_one_cell_instead_of_narrowing() { + let d = store(); + let full = key("a2", "b3", "c").0; + let mut got = Vec::new(); + assert_eq!( + narrowings(|| got = d.matching_values(&full).into_iter().cloned().collect()), + 0 + ); + assert_eq!(got, vec![CellValue::Number(15.0)]); + let missing = key("a2", "b9", "c").0; + assert!( + d.matching_values(&missing).is_empty(), + "an absent cell matches nothing" + ); + let unknown = key("a2", "b3", "zzz").0; + assert!( + d.matching_values(&unknown).is_empty(), + "an unseen item matches nothing" + ); + } + + /// The control: a narrower partial still sums its supersets, so the shortcut + /// is not a blanket exact lookup. + #[test] + fn a_narrower_partial_still_narrows_and_matches_every_superset() { + let d = store(); + let narrow = vec![ + ("A".to_string(), "a2".to_string()), + ("C".to_string(), "c".to_string()), + ]; + let mut n = 0; + assert_eq!(narrowings(|| n = d.matching_values(&narrow).len()), 1); + assert_eq!(n, 6); + } + + /// A wider cell appearing, or leaving, moves the line the shortcut is allowed at. + #[test] + fn a_wider_cell_turns_the_shortcut_off_until_it_is_removed() { + let mut d = store(); + let wide = CellKey::new(vec![ + ("A".into(), "a2".into()), + ("B".into(), "b3".into()), + ("C".into(), "c".into()), + ("D".into(), "d".into()), + ]); + d.set(wide.clone(), CellValue::Number(100.0)); + let full = key("a2", "b3", "c").0; + assert_eq!( + d.matching_values(&full).len(), + 2, + "the exact cell and the wider cell under it" + ); + d.remove(&wide); + assert_eq!( + narrowings(|| assert_eq!(d.matching_values(&full).len(), 1)), + 0 + ); + } + + /// An unsorted partial is not a stored key's order, so it must not be probed. + #[test] + fn an_unsorted_full_width_partial_is_still_answered() { + let d = store(); + let mut full = key("a2", "b3", "c").0; + full.reverse(); + assert_eq!(d.matching_values(&full).len(), 1); + } +} diff --git a/crates/improvise-core/src/model/id.rs b/crates/improvise-core/src/model/id.rs new file mode 100644 index 00000000..7448a647 --- /dev/null +++ b/crates/improvise-core/src/model/id.rs @@ -0,0 +1,290 @@ +//! How an id is made: `slugify`, and the document's `IdAllocator` (ADR 007 clause 4). +//! Reasoning: `context/code/core.md` §20. +use std::collections::HashSet; + +/// What a generated id names; spells the `` of an empty slug's `-`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdKind { + Category, + Item, + Formula, + View, +} + +impl IdKind { + pub fn name(self) -> &'static str { + match self { + IdKind::Category => "category", + IdKind::Item => "item", + IdKind::Formula => "formula", + IdKind::View => "view", + } + } +} + +/// The only ids that may begin with `_` (answer 23). +pub const VIRTUAL_IDS: [&str; 3] = ["_index", "_dim", "_measure"]; + +/// A display name's slug; empty when nothing survives (answers 16, 18, 21, 23, 26). +pub fn slugify(display: &str) -> String { + let folded = unicase::UniCase::new(display).to_folded_case(); + let kept: String = underscore_space_runs(&folded) + .chars() + .filter(|&c| in_id_alphabet(c)) + .collect(); + kept.trim_start_matches('_').to_string() +} + +fn underscore_space_runs(s: &str) -> String { + let (out, _) = s + .chars() + .fold((String::new(), false), |(mut out, prev), c| { + let space = c == ' '; + if !(space && prev) { + out.push(if space { '_' } else { c }); + } + (out, space) + }); + out +} + +fn in_id_alphabet(c: char) -> bool { + matches!(c, 'a'..='z' | '0'..='9' | '-' | '_') +} + +/// Why a hand-written id cannot enter the document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimError { + Duplicate(String), + Malformed(String), + Reserved(String), +} + +/// One document's ids: every id it has held this session, and the `gensym` counter. +#[derive(Debug, Clone)] +pub struct IdAllocator { + next: u64, + seen: HashSet, +} + +impl Default for IdAllocator { + fn default() -> Self { + Self { + next: 1, + seen: HashSet::new(), + } + } +} + +impl IdAllocator { + pub fn new() -> Self { + Self::default() + } + + /// Admit a hand-written id verbatim: it is canonical and never renamed (answer 29). + pub fn claim(&mut self, id: &str) -> Result<(), ClaimError> { + let refusal = if id.is_empty() || !id.chars().all(in_id_alphabet) { + Some(ClaimError::Malformed(id.to_string())) + } else if id.starts_with('_') && !VIRTUAL_IDS.contains(&id) { + Some(ClaimError::Reserved(id.to_string())) + } else if self.seen.contains(id) { + Some(ClaimError::Duplicate(id.to_string())) + } else { + None + }; + match refusal { + Some(e) => Err(e), + None => { + self.seen.insert(id.to_string()); + Ok(()) + } + } + } + + /// A fresh id for `display`, suffixed from the counter only when the slug is taken. + pub fn generate(&mut self, kind: IdKind, display: &str) -> String { + let slug = slugify(display); + let id = if slug.is_empty() { + self.suffixed(kind.name()) + } else if self.seen.contains(&slug) { + self.suffixed(&slug) + } else { + slug + }; + self.seen.insert(id.clone()); + id + } + + fn suffixed(&mut self, base: &str) -> String { + loop { + let candidate = format!("{base}-{}", self.next); + self.next += 1; + if !self.seen.contains(&candidate) { + break candidate; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + #[test] + fn slugify_follows_the_adrs_examples() { + let cases = [ + ("East", "east"), + ("Time Period", "time_period"), + ("Coastal_East", "coastal_east"), + ("Q1-2024", "q1-2024"), + ("Region(code)", "regioncode"), + ("Café", "caf"), + ("_Draft", "draft"), + ("Ü", ""), + ]; + for (display, slug) in cases { + assert_eq!(slugify(display), slug, "slugify({display:?})"); + } + } + + #[test] + fn slugify_case_folds_rather_than_lowercases() { + assert_eq!(slugify("Straße"), "strasse"); + assert_eq!(slugify("ſtate"), "state"); + assert_eq!(slugify("file"), "file"); + } + + #[test] + fn slugify_collapses_a_run_of_spaces_to_one_underscore() { + assert_eq!(slugify("Time Period"), "time_period"); + assert_eq!(slugify("East "), "east_"); + } + + #[test] + fn slugify_keeps_an_underscore_the_display_already_had() { + assert_eq!(slugify("a_ b"), "a__b"); + assert_eq!(slugify("a__b"), "a__b"); + } + + #[test] + fn an_empty_slug_takes_its_kind_and_the_shared_counter() { + let mut ids = IdAllocator::new(); + assert_eq!(ids.generate(IdKind::Category, "Ü"), "category-1"); + assert_eq!(ids.generate(IdKind::Item, "Ö"), "item-2"); + assert_eq!(ids.generate(IdKind::View, ""), "view-3"); + } + + #[test] + fn a_slug_taken_by_another_kind_takes_a_suffix() { + let mut ids = IdAllocator::new(); + assert_eq!(ids.generate(IdKind::Category, "East"), "east"); + assert_eq!(ids.generate(IdKind::Item, "East"), "east-1"); + assert_eq!(ids.generate(IdKind::View, "Time Period"), "time_period"); + assert_eq!( + ids.generate(IdKind::Formula, "Time_Period"), + "time_period-2" + ); + } + + #[test] + fn the_suffix_is_the_next_counter_value_not_the_lowest_free_number() { + let mut ids = IdAllocator::new(); + assert_eq!(ids.generate(IdKind::Item, "East"), "east"); + assert_eq!(ids.generate(IdKind::Item, "West"), "west"); + assert_eq!(ids.generate(IdKind::Item, "East"), "east-1"); + assert_eq!(ids.generate(IdKind::Item, "West"), "west-2"); + assert_eq!(ids.generate(IdKind::Item, "EAST"), "east-3"); + } + + #[test] + fn a_claimed_id_keeps_its_spelling_and_generation_steps_past_it() { + let mut ids = IdAllocator::new(); + assert_eq!(ids.claim("east-1"), Ok(())); + assert_eq!(ids.generate(IdKind::Item, "East"), "east"); + assert_eq!(ids.generate(IdKind::Item, "East"), "east-2"); + } + + #[test] + fn claim_refuses_a_duplicate_a_malformed_and_a_reserved_id() { + let mut ids = IdAllocator::new(); + assert_eq!(ids.claim("east"), Ok(())); + assert_eq!(ids.claim("east"), Err(ClaimError::Duplicate("east".into()))); + assert_eq!(ids.claim("East"), Err(ClaimError::Malformed("East".into()))); + assert_eq!(ids.claim(""), Err(ClaimError::Malformed(String::new()))); + assert_eq!( + ids.claim("_draft"), + Err(ClaimError::Reserved("_draft".into())) + ); + assert_eq!(ids.claim("_measure"), Ok(())); + let generated = ids.generate(IdKind::Category, "West"); + assert_eq!( + ids.claim(&generated), + Err(ClaimError::Duplicate("west".into())) + ); + } + + #[test] + fn claim_refuses_an_id_that_only_begins_like_a_virtual_one() { + let mut ids = IdAllocator::new(); + for lookalike in ["_indexer", "_measure-1", "_dim_2", "_measures"] { + assert_eq!( + ids.claim(lookalike), + Err(ClaimError::Reserved(lookalike.into())) + ); + } + } + + #[test] + fn a_display_with_leading_spaces_never_slugs_into_the_reserved_space() { + assert_eq!(slugify(" Draft"), "draft"); + assert_eq!(slugify(" _ Draft"), "draft"); + } + + #[test] + fn a_long_claimed_run_of_suffixes_is_walked_without_recursion() { + let mut ids = IdAllocator::new(); + for n in 1..=20_000 { + assert_eq!(ids.claim(&format!("item-{n}")), Ok(())); + } + assert_eq!(ids.generate(IdKind::Item, "Ü"), "item-20001"); + assert_eq!(ids.generate(IdKind::Category, ""), "category-20002"); + } + + #[test] + fn two_allocators_fed_alike_generate_alike() { + let displays = [ + "East", + "east", + "Ü", + "Time Period", + "Time_Period", + "", + "EAST", + ]; + let run = || { + let mut ids = IdAllocator::new(); + displays + .iter() + .map(|d| ids.generate(IdKind::Item, d)) + .collect::>() + }; + assert_eq!(run(), run()); + } + + proptest! { + #[test] + fn generated_ids_are_distinct_well_formed_and_never_reserved( + displays in proptest::collection::vec(".{0,6}", 0..24) + ) { + let mut ids = IdAllocator::new(); + let mut out = HashSet::new(); + for d in &displays { + let id = ids.generate(IdKind::Item, d); + prop_assert!(!id.is_empty() && !id.starts_with('_'), "{id:?}"); + prop_assert!(id.chars().all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_'))); + prop_assert!(out.insert(id.clone()), "reissued {id:?}"); + } + } + } +} diff --git a/crates/improvise-core/src/model/mod.rs b/crates/improvise-core/src/model/mod.rs index 03dc766e..f0c5abb7 100644 --- a/crates/improvise-core/src/model/mod.rs +++ b/crates/improvise-core/src/model/mod.rs @@ -1,7 +1,8 @@ pub mod category; pub mod cell; +pub mod id; pub mod record; pub mod symbol; pub mod types; -pub use types::{Aggregation, Model}; +pub use types::{Aggregation, MAX_CATEGORIES, Model}; diff --git a/crates/improvise-core/src/model/types.rs b/crates/improvise-core/src/model/types.rs index fffeba25..4068121f 100644 --- a/crates/improvise-core/src/model/types.rs +++ b/crates/improvise-core/src/model/types.rs @@ -7,7 +7,9 @@ use super::category::{Category, CategoryId, CategoryKind, MEASURE_CATEGORY}; use super::cell::{CellKey, CellValue, DataStore}; use crate::formula::{AggFunc, Expr, Filter, Formula, FormulaId}; -const MAX_CATEGORIES: usize = 12; +/// Regular categories a model may hold; labels and virtuals do not count. +/// Why 24 and what it costs: context/code/core.md §9 (improvise-felo.1). +pub const MAX_CATEGORIES: usize = 24; /// Shared by `=`/`!=` **and** the division-by-zero guard, in both evaluators. /// They must agree; why, and why 1e-10: context/code/core.md §4. @@ -523,6 +525,15 @@ impl Model { self.categories.get(name) } + /// `group` and its ancestors in `cat`, root first and owned: the chain a + /// `View` folds by (core.md §19). Just `[group]` where `cat` is unknown. + pub fn group_chain(&self, cat: &str, group: &str) -> Vec { + match self.category(cat) { + Some(c) => c.group_chain(group).into_iter().map(String::from).collect(), + None => vec![group.to_string()], + } + } + pub fn set_cell(&mut self, key: CellKey, value: CellValue) { self.data.set(key, value); } @@ -1386,7 +1397,8 @@ mod model_tests { assert_eq!(id, m.categories["C0"].id); assert_eq!(m.categories.len(), before, "nothing was created"); // The limit still refuses a category that WOULD be created. - assert!(m.add_category("C12").is_err(), "the limit still holds"); + let fresh = format!("C{}", super::MAX_CATEGORIES); + assert!(m.add_category(fresh).is_err(), "the limit still holds"); } /// improvise-s08t — see context/test-evidence/core.md. @@ -1550,13 +1562,20 @@ mod model_tests { assert_eq!(m.category_names().len(), 4); } + /// The user's number (improvise-felo.1), spelled once here; every other + /// fill-to-the-cap test derives it from `MAX_CATEGORIES`. #[test] fn add_category_max_limit() { let mut m = Model::new("Test"); - for i in 0..12 { - m.add_category(format!("Cat{i}")).unwrap(); + for i in 0..24 { + m.add_category(format!("Cat{i}")) + .unwrap_or_else(|e| panic!("category {} of 24 refused: {e}", i + 1)); } - assert!(m.add_category("TooMany").is_err()); + let err = m.add_category("TooMany").expect_err("the 25th is refused"); + assert!( + err.to_string().contains("24"), + "the refusal names the real limit: {err}" + ); } #[test] @@ -3152,11 +3171,11 @@ mod five_category { // 4 regular + 3 virtual. assert_eq!(m.category_names().len(), 7); let mut m2 = build_model(); - for i in 0..8 { + for i in 0..super::MAX_CATEGORIES - 4 { m2.add_category(format!("Extra{i}")).unwrap(); } - // 12 regular + 3 virtuals = 15. - assert_eq!(m2.category_names().len(), 15); + // The limit's worth of regular categories, plus 3 virtuals. + assert_eq!(m2.category_names().len(), super::MAX_CATEGORIES + 3); assert!(m2.add_category("OneMore").is_err()); } } @@ -3225,7 +3244,7 @@ mod prop_tests { if let Some(c) = m.category_mut("_Measure") { c.add_item("Revenue"); } let key = CellKey::new(vec![("_Measure".into(), "Revenue".into())]); m.set_cell(key.clone(), CellValue::Number(val)); - let _ = m.add_category(&new_cat); // may hit the 12-cat limit + let _ = m.add_category(&new_cat); // may be refused; either way the cell survives prop_assert_eq!(m.evaluate(&key), Some(CellValue::Number(val))); } diff --git a/crates/improvise-core/src/view/axis.rs b/crates/improvise-core/src/view/axis.rs index ae55a145..77860d28 100644 --- a/crates/improvise-core/src/view/axis.rs +++ b/crates/improvise-core/src/view/axis.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Axis { Row, @@ -12,17 +12,6 @@ pub enum Axis { None, } -/// Accent color for an axis. Semantic palette only; `draw.rs::axis_fg` maps -/// each variant to a terminal color, so `Axis` names no ratatui type. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AxisColor { - Row, - Column, - Page, - Filter, - Inactive, -} - impl Axis { /// The next axis in the Row → Column → Page → None → Row rotation. /// `Filter` is outside it and cycling one is a no-op (improvise-dz8). @@ -48,18 +37,6 @@ impl Axis { !matches!(self, Axis::Filter) } - /// Accent color. The single source for axis coloring: the tile bar and the - /// category panel each carried a copy before (improvise-rml). - pub fn color(self) -> AxisColor { - match self { - Axis::Row => AxisColor::Row, - Axis::Column => AxisColor::Column, - Axis::Page => AxisColor::Page, - Axis::Filter => AxisColor::Filter, - Axis::None => AxisColor::Inactive, - } - } - /// Name with no decoration: "Row", "Col", "Page", "None". pub fn plain_label(self) -> &'static str { match self { @@ -176,15 +153,4 @@ mod tests { assert!(!Axis::Column.filters_data()); assert!(!Axis::None.filters_data()); } - - /// A collision defeats the only reason `AxisColor` exists. - #[test] - fn every_axis_gets_its_own_color() { - let colors = ALL.map(Axis::color); - for (i, a) in colors.iter().enumerate() { - for b in &colors[i + 1..] { - assert_ne!(a, b, "two axes share an accent color"); - } - } - } } diff --git a/crates/improvise-core/src/view/layout.rs b/crates/improvise-core/src/view/layout.rs index b139d4db..5b4c93fa 100644 --- a/crates/improvise-core/src/view/layout.rs +++ b/crates/improvise-core/src/view/layout.rs @@ -205,16 +205,19 @@ pub enum RecordCol { /// and collapse state show; a collapsed group is ONE `DataItem` (core.md §14). #[derive(Debug, Clone, PartialEq)] pub enum AxisEntry { - /// Opens a run at `level` (the category's place on the axis) and closes - /// every run inside it. Membership is per level: core.md §14, improvise-e265. + /// Opens a run at `(level, depth)` — the category's place on the axis, then the + /// group's depth in its hierarchy — and closes every run inside it (§6, §19). GroupHeader { level: usize, + depth: usize, cat_name: String, group_name: String, + /// The view's answer for this group, so no reader re-derives it. + collapsed: bool, }, - /// Ends the run at `level` and every run inside it (improvise-gd83, e265). - /// Nothing is drawn for it; it exists so membership is not inherited. - GroupEnd { level: usize }, + /// Ends the run at `(level, depth)` and every run inside it (improvise-gd83, + /// e265). Nothing is drawn for it; it exists so membership is not inherited. + GroupEnd { level: usize, depth: usize }, /// `collapsed` is not derivable from `tuple`: a group and an item may share a /// name. Set for a folded group and for everything expanded beneath one. DataItem { tuple: Vec, collapsed: bool }, @@ -232,23 +235,47 @@ impl AxisEntry { /// The data-item tuples on an axis, skipping group headers. Every public /// `row`/`col` argument indexes this sequence, not the visual entry list. +#[cfg(test)] fn data_items(items: &[AxisEntry]) -> impl Iterator> { - items.iter().filter_map(|e| match e { - AxisEntry::DataItem { tuple, .. } => Some(tuple), - AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, - }) -} - -/// Whether the data item at `idx` folds a collapsed group. -fn folds(items: &[AxisEntry], idx: usize) -> bool { items .iter() .filter_map(|e| match e { - AxisEntry::DataItem { collapsed, .. } => Some(*collapsed), + AxisEntry::DataItem { tuple, .. } => Some(tuple), AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, }) - .nth(idx) - .unwrap_or(false) + .inspect(|_| DATA_ITEM_STEPS.with(|n| n.set(n.get() + 1))) +} + +#[cfg(test)] +thread_local! { + /// Items `data_items` has yielded on this thread: the work a lookup did. + static DATA_ITEM_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Entry `positions(items)[n]` is data item `n`: a lookup by data index is O(1), +/// not a walk past every header before it (layout-cost.md). +fn positions(items: &[AxisEntry]) -> Vec { + items + .iter() + .enumerate() + .filter_map(|(at, e)| matches!(e, AxisEntry::DataItem { .. }).then_some(at)) + .collect() +} + +/// The data item at `idx`, by the axis's position index. +fn data_item<'a>(items: &'a [AxisEntry], pos: &[usize], idx: usize) -> Option<&'a Vec> { + match items.get(*pos.get(idx)?)? { + AxisEntry::DataItem { tuple, .. } => Some(tuple), + AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. } => None, + } +} + +/// Whether the data item at `idx` folds a collapsed group. +fn folds(items: &[AxisEntry], pos: &[usize], idx: usize) -> bool { + match pos.get(idx).and_then(|at| items.get(*at)) { + Some(AxisEntry::DataItem { collapsed, .. }) => *collapsed, + Some(AxisEntry::GroupHeader { .. } | AxisEntry::GroupEnd { .. }) | None => false, + } } /// The data index of the first item in the run `cat`/`group` opens under @@ -284,45 +311,49 @@ fn run_start( } /// The display label of the data item at `idx`, or empty when out of bounds. -fn axis_label(items: &[AxisEntry], idx: usize) -> String { - data_items(items) - .nth(idx) +fn axis_label(items: &[AxisEntry], pos: &[usize], idx: usize) -> String { + data_item(items, pos, idx) .map(|v| v.join("/")) .unwrap_or_default() } /// Visual index of the nth data entry (skipping group headers). -fn data_index_to_visual(items: &[AxisEntry], data_idx: usize) -> Option { - items - .iter() - .enumerate() - .filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. })) - .map(|(vi, _)| vi) - .nth(data_idx) +fn data_index_to_visual(pos: &[usize], data_idx: usize) -> Option { + pos.get(data_idx).copied() } -/// Each open run, by level, as `(position of its header, the header)`. A header -/// at L replaces L and closes deeper runs; a `GroupEnd` at L closes L and deeper. -type OpenRuns = Vec>; +/// Where a run sits: `(level, depth)`, ordered, so "inside" is "greater than". +/// Across categories the level decides; within one, the depth (§19). +pub type RunKey = (usize, usize); -fn open_at(runs: &mut OpenRuns, level: usize, run: Option<(usize, AxisEntry)>) { - runs.truncate(level); - runs.resize(level, None); - runs.extend(run.map(Some)); +/// One open run: its key and the position of the header that opened it. +pub type OpenRun = (RunKey, usize); + +/// Each open run, innermost last, with its header. A header at K replaces K +/// and closes every deeper run; a `GroupEnd` at K closes K and deeper. +type OpenRuns = Vec<(RunKey, usize, AxisEntry)>; + +/// Close every run at or inside `key`, then open `run` there if one is given. +fn open_at(runs: &mut OpenRuns, key: RunKey, run: Option<(usize, AxisEntry)>) { + let outside = runs.partition_point(|(k, _, _)| *k < key); + runs.truncate(outside); + runs.extend(run.map(|(at, header)| (key, at, header))); } /// Drop the data items `keep` rejects, then re-mark the survivors so each keeps /// exactly the runs it was in. Why re-marked, not filtered: core.md §6, e265. fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { let mut kept: Vec = Vec::new(); - // What the input has open at each level, and what `kept` has open so far. + // What the input has open, and what `kept` has open so far. let mut input: OpenRuns = Vec::new(); - let mut output: Vec> = Vec::new(); + let mut output: Vec = Vec::new(); let mut data_idx = 0usize; for (at, entry) in items.drain(..).enumerate() { match entry { - AxisEntry::GroupHeader { level, .. } => open_at(&mut input, level, Some((at, entry))), - AxisEntry::GroupEnd { level } => open_at(&mut input, level, None), + AxisEntry::GroupHeader { level, depth, .. } => { + open_at(&mut input, (level, depth), Some((at, entry))) + } + AxisEntry::GroupEnd { level, depth } => open_at(&mut input, (level, depth), None), AxisEntry::DataItem { .. } => { if keep(data_idx) { remark(&input, &mut output, &mut kept); @@ -335,48 +366,58 @@ fn prune_axis_items(items: &mut Vec, keep: impl Fn(usize) -> bool) { *items = kept; } -/// Emit the markers that make `kept`'s open runs equal the input's, outermost -/// first: a header where a different run is open, a `GroupEnd` where none is. -fn remark(input: &OpenRuns, output: &mut Vec>, kept: &mut Vec) { - for level in 0..input.len().max(output.len()) { - let want = input.get(level).cloned().flatten(); - let have = output.get(level).copied().flatten(); - if want.as_ref().map(|(at, _)| *at) == have { - continue; - } - // Deeper runs end first, innermost out, as `expand_category` ends them. - for deeper in (level + 1..output.len()).rev() { - if output[deeper].is_some() { - kept.push(AxisEntry::GroupEnd { level: deeper }); - } - } - output.truncate(level); - output.resize(level, None); - match want { - Some((at, header)) => { - kept.push(header); - output.push(Some(at)); - } - None => kept.push(AxisEntry::GroupEnd { level }), - } +fn group_end((level, depth): RunKey) -> AxisEntry { + AxisEntry::GroupEnd { level, depth } +} + +/// Emit the markers that make `kept`'s open runs equal the input's: past the +/// runs they share, end the deeper ones innermost out, then open what differs. +fn remark(input: &OpenRuns, output: &mut Vec, kept: &mut Vec) { + let shared = input + .iter() + .zip(output.iter()) + .take_while(|((_, want, _), (_, have))| want == have) + .count(); + let want = input.get(shared); + let have = output.get(shared).copied(); + let Some(slot) = [want.map(|w| w.0), have.map(|h| h.0)] + .into_iter() + .flatten() + .min() + else { + return; + }; + // Deeper runs end first, innermost out, as `expand_category` ends them. + for (key, _) in output[shared..].iter().rev().filter(|(k, _)| *k > slot) { + kept.push(group_end(*key)); + } + output.truncate(shared); + let replaces = want.is_some_and(|w| w.0 == slot); + if !replaces && have.is_some_and(|h| h.0 == slot) { + kept.push(group_end(slot)); + } + for (key, at, header) in &input[shared..] { + kept.push(header.clone()); + output.push((*key, *at)); } } -/// The one reading of the markers: each data item's open runs, by level, as -/// header positions, lent to `visit` — one stack, never copied (0r01). +/// The one reading of the markers: each data item's open runs, innermost last, +/// lent to `visit` — one stack, never copied (0r01). fn walk_open_runs( items: &[AxisEntry], - mut visit: impl FnMut(&[Option]) -> ControlFlow, + mut visit: impl FnMut(&[OpenRun]) -> ControlFlow, ) -> Option { - let mut open: Vec> = Vec::new(); + let mut open: Vec = Vec::new(); for (at, entry) in items.iter().enumerate() { match entry { - AxisEntry::GroupHeader { level, .. } => { - open.truncate(*level); - open.resize(*level, None); - open.push(Some(at)); + AxisEntry::GroupHeader { level, depth, .. } => { + open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth))); + open.push(((*level, *depth), at)); + } + AxisEntry::GroupEnd { level, depth } => { + open.truncate(open.partition_point(|(k, _)| *k < (*level, *depth))) } - AxisEntry::GroupEnd { level } => open.truncate(*level), AxisEntry::DataItem { .. } => { if let ControlFlow::Break(found) = visit(&open) { return Some(found); @@ -392,7 +433,7 @@ fn walk_open_runs( fn innermost_group(items: &[AxisEntry], idx: usize) -> Option<(String, String)> { let mut data_idx = 0usize; let at = walk_open_runs(items, |open| match data_idx == idx { - true => ControlFlow::Break(open.iter().rev().find_map(|run| *run)), + true => ControlFlow::Break(open.last().map(|(_, at)| *at)), false => { data_idx += 1; ControlFlow::Continue(()) @@ -445,6 +486,10 @@ pub struct GridLayout { pub page_coords: Vec, pub row_items: Vec, pub col_items: Vec, + /// `positions(row_items)` / `positions(col_items)`: derived, so every writer of + /// the items calls `reindex`, and nothing outside this module writes them. + row_pos: Vec, + col_pos: Vec, /// Categories on `Axis::None` — hidden, implicitly aggregated. pub none_cats: Vec, /// Records mode: one entry per row, grouping the cells that share every @@ -474,6 +519,7 @@ impl GridLayout { .collect(); layout.row_items = row_items; layout.records = Some(records); + layout.reindex(); } if view.prune_empty { layout.prune_empty(model); @@ -533,6 +579,8 @@ impl GridLayout { row_cats, col_cats, page_coords, + row_pos: positions(&row_items), + col_pos: positions(&col_items), row_items, col_items, none_cats, @@ -567,6 +615,8 @@ impl GridLayout { row_cats: vec![INDEX_CATEGORY.to_string()], col_cats: vec![DIM_CATEGORY.to_string()], page_coords, + row_pos: positions(&row_items), + col_pos: positions(&col_items), row_items, col_items, none_cats, @@ -705,6 +755,13 @@ impl GridLayout { prune_axis_items(&mut self.col_items, |ci| { (0..rc).any(|ri| has_value[ri][ci]) }); + self.reindex(); + } + + /// Rebuild the position indexes after the items change. + fn reindex(&mut self) { + self.row_pos = positions(&self.row_items); + self.col_pos = positions(&self.col_items); } /// Whether this layout is in records mode. @@ -714,20 +771,20 @@ impl GridLayout { /// Number of data rows (group headers excluded). pub fn row_count(&self) -> usize { - data_items(&self.row_items).count() + self.row_pos.len() } /// Number of data columns (group headers excluded). pub fn col_count(&self) -> usize { - data_items(&self.col_items).count() + self.col_pos.len() } pub fn row_label(&self, row: usize) -> String { - axis_label(&self.row_items, row) + axis_label(&self.row_items, &self.row_pos, row) } pub fn col_label(&self, col: usize) -> String { - axis_label(&self.col_items, col) + axis_label(&self.col_items, &self.col_pos, col) } /// The value of a pivot key as THIS grid reads it: aggregated over its None @@ -781,8 +838,8 @@ impl GridLayout { (DIM_CATEGORY.to_string(), col.to_string()), ])); } - let row_item = data_items(&self.row_items).nth(row)?; - let col_item = data_items(&self.col_items).nth(col)?; + let row_item = data_item(&self.row_items, &self.row_pos, row)?; + let col_item = data_item(&self.col_items, &self.col_pos, col)?; let mut coords = page_constraints(&self.page_coords); for (cat, item) in self.row_cats.iter().zip(row_item.iter()) { coords.push((cat.clone(), item.clone())); @@ -796,7 +853,7 @@ impl GridLayout { /// The coordinates a COLUMN answers for once its rows are aggregated away: /// the page/filter constraints and the column's own tuple (core.md §6). pub fn column_key(&self, col: usize) -> Option { - let col_item = data_items(&self.col_items).nth(col)?; + let col_item = data_item(&self.col_items, &self.col_pos, col)?; let mut coords = page_constraints(&self.page_coords); for (cat, item) in self.col_cats.iter().zip(col_item.iter()) { coords.push((cat.clone(), item.clone())); @@ -806,12 +863,12 @@ impl GridLayout { /// Visual index of the nth data row (skipping group headers). pub fn data_row_to_visual(&self, data_row: usize) -> Option { - data_index_to_visual(&self.row_items, data_row) + data_index_to_visual(&self.row_pos, data_row) } /// Visual index of the nth data column (skipping group headers). pub fn data_col_to_visual(&self, data_col: usize) -> Option { - data_index_to_visual(&self.col_items, data_col) + data_index_to_visual(&self.col_pos, data_col) } /// The `(cat_name, group_name)` of the innermost group the Nth data row @@ -823,12 +880,12 @@ impl GridLayout { /// Whether the cell at `(row, col)` folds a collapsed group: an aggregate /// with no record under it. The one home of that answer (§14). pub fn folds_a_group(&self, row: usize, col: usize) -> bool { - self.row_folds_a_group(row) || folds(&self.col_items, col) + self.row_folds_a_group(row) || folds(&self.col_items, &self.col_pos, col) } /// The row half of [`Self::folds_a_group`], for a command about the row alone. pub fn row_folds_a_group(&self, row: usize) -> bool { - folds(&self.row_items, row) + folds(&self.row_items, &self.row_pos, row) } /// The first data row of `cat`/`group`'s run under `prefix` — where a toggle @@ -844,12 +901,12 @@ impl GridLayout { /// The coordinate tuple of the Nth data row. pub fn row_tuple(&self, row: usize) -> Option<&[String]> { - data_items(&self.row_items).nth(row).map(Vec::as_slice) + data_item(&self.row_items, &self.row_pos, row).map(Vec::as_slice) } /// The coordinate tuple of the Nth data column. pub fn col_tuple(&self, col: usize) -> Option<&[String]> { - data_items(&self.col_items).nth(col).map(Vec::as_slice) + data_item(&self.col_items, &self.col_pos, col).map(Vec::as_slice) } /// The `(cat_name, group_name)` of the innermost group the Nth data @@ -872,9 +929,13 @@ fn expand_category( return vec![]; }; // The prefix holds one item per outer category, so its length is this level. - let level = prefix.len(); - let mut result = Vec::new(); - let mut last_group: Option<&str> = None; + let mut runs = CategoryRuns { + level: prefix.len(), + cat_name, + view, + open: Vec::new(), + out: Vec::new(), + }; // A group's items need not be contiguous, so a collapsed one folds ONCE. let mut folded: Vec<&str> = Vec::new(); @@ -884,50 +945,79 @@ fn expand_category( if view.is_hidden(cat_name, item_name) { continue; } - let item_group = cat - .items - .get(item_name.as_str()) - .and_then(|i| i.group.as_deref()); - let collapsed = item_group.filter(|g| view.is_group_collapsed(cat_name, g)); - if collapsed.is_some_and(|g| folded.contains(&g)) { + let chain = cat.item_chain(item_name); + // The OUTERMOST collapsed group folds every leaf beneath it (§19). + let fold_depth = + (0..chain.len()).find(|&depth| view.is_group_collapsed(cat_name, &chain[..=depth])); + let fold = fold_depth.map(|depth| chain[depth]); + if fold.is_some_and(|g| folded.contains(&g)) { continue; } - - // A header on entering a group; a `GroupEnd` only on leaving one for - // NO group — another group's header already ends the run. - if item_group != last_group { - if last_group.is_some() && item_group.is_none() { - result.push(AxisEntry::GroupEnd { level }); - } - if let Some(g) = item_group { - result.push(AxisEntry::GroupHeader { - level, - cat_name: cat_name.to_string(), - group_name: g.to_string(), - }); - } - last_group = item_group; - } + runs.enter(&chain[..fold_depth.map_or(chain.len(), |d| d + 1)]); let mut tuple = prefix.clone(); - match collapsed { + match fold { Some(g) => { folded.push(g); tuple.push(g.to_string()); } None => tuple.push(item_name.to_string()), } - result.push(AxisEntry::DataItem { + runs.out.push(AxisEntry::DataItem { tuple, - collapsed: prefix_collapsed || collapsed.is_some(), + collapsed: prefix_collapsed || fold.is_some(), }); } // The expansion is the end of the run too: the next prefix's items follow // immediately and must not inherit it (improvise-gd83). - if last_group.is_some() { - result.push(AxisEntry::GroupEnd { level }); + runs.enter(&[]); + runs.out +} + +/// One category's group runs as `expand_category` emits them: the chain of groups +/// open, root first, and the markers that move it from item to item. +struct CategoryRuns<'a> { + level: usize, + cat_name: &'a str, + view: &'a View, + open: Vec<&'a str>, + out: Vec, +} + +impl<'a> CategoryRuns<'a> { + /// Move the open chain to `chain` in `remark`'s own convention, so an unpruned + /// axis survives pruning marker for marker (core.md §6). + fn enter(&mut self, chain: &[&'a str]) { + let shared = self + .open + .iter() + .zip(chain) + .take_while(|(open, want)| open == want) + .count(); + let level = self.level; + for depth in (shared + 1..self.open.len()).rev() { + self.out.push(AxisEntry::GroupEnd { level, depth }); + } + if chain.len() == shared && self.open.len() > shared { + self.out.push(AxisEntry::GroupEnd { + level, + depth: shared, + }); + } + self.open.truncate(shared); + for (depth, group) in chain.iter().enumerate().skip(shared) { + self.out.push(AxisEntry::GroupHeader { + level, + depth, + cat_name: self.cat_name.to_string(), + group_name: group.to_string(), + collapsed: self + .view + .is_group_collapsed(self.cat_name, &chain[..=depth]), + }); + self.open.push(group); + } } - result } /// Cartesian product of visible items across `cats`, headers always emitted. @@ -1026,8 +1116,10 @@ mod tests { AxisEntry::item(vec!["A".into()]), AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "C".into(), group_name: "G".into(), + collapsed: false, }, AxisEntry::item(vec!["B".into()]), AxisEntry::item(vec!["C".into()]), @@ -1051,8 +1143,10 @@ mod tests { let mut items = vec![ AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "C".into(), group_name: "G".into(), + collapsed: false, }, AxisEntry::item(vec!["A".into()]), AxisEntry::item(vec!["B".into()]), @@ -1418,6 +1512,7 @@ mod tests { wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!(layout.row_count(), 3); // Jan, Feb, Apr — headers don't count @@ -1430,6 +1525,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); let headers: Vec<_> = layout @@ -1457,8 +1553,9 @@ mod tests { wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "May", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); let labels: Vec = (0..layout.row_count()) .map(|r| layout.row_label(r)) @@ -1489,8 +1586,9 @@ mod tests { wb.add_item_in_group("Month", "A", "G1").unwrap(); wb.add_item_in_group("Month", "B", "G2").unwrap(); wb.add_item_in_group("Month", "C", "G1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); - wb.active_view_mut().toggle_group_collapse("Month", "G1"); + wb.active_view_mut().toggle_group_collapse("Month", &["G1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); let labels: Vec = (0..layout.row_count()) .map(|r| layout.row_label(r)) @@ -1518,10 +1616,11 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Region", "North").unwrap(); wb.add_item("Type", "Food").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.row_tuple(0), @@ -1543,6 +1642,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Month", "Sep").unwrap(); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); @@ -1572,6 +1672,7 @@ mod tests { wb.add_item_in_group("Recipient", "Alice", "Family") .unwrap(); wb.add_item_in_group("Recipient", "Bob", "Family").unwrap(); + wb.active_view_mut().set_expand_depth("Recipient", 1); wb.active_view_mut().set_axis("Recipient", Axis::Row); let layout = GridLayout::new(&wb.model, wb.active_view()); @@ -1612,6 +1713,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); wb.model.set_cell( coord(&[("Month", "Apr"), ("Type", "Food")]), @@ -1630,6 +1732,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); // visual: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] @@ -1646,6 +1749,7 @@ mod tests { wb.add_item("Type", "Food").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); let layout = GridLayout::new(&wb.model, wb.active_view()); // col_items: [GroupHeader(Q1), DataItem(Jan), GroupHeader(Q2), DataItem(Apr)] assert_eq!(layout.data_col_to_visual(0), Some(1)); @@ -1660,6 +1764,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( @@ -1691,6 +1796,7 @@ mod tests { wb.add_item("Type", "Food").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); let layout = GridLayout::new(&wb.model, wb.active_view()); assert_eq!( layout.col_group_for(0), @@ -1725,6 +1831,7 @@ mod tests { None => wb.add_item("Month", *name).unwrap(), }; } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for m in funded { wb.model.set_cell( @@ -1755,7 +1862,7 @@ mod tests { &[("Jan", Some("Q1")), ("Mar", None), ("Sep", None)], &["Jan", "Sep"], ); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); // The whole sequence, not `any(is Q1)`: that cannot see a header // pushed in the wrong place, nor a stray `GroupEnd`. @@ -1764,14 +1871,16 @@ mod tests { vec![ AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "Month".into(), - group_name: "Q1".into() + group_name: "Q1".into(), + collapsed: true, }, AxisEntry::DataItem { tuple: vec!["Q1".into()], collapsed: true }, - AxisEntry::GroupEnd { level: 0 }, + AxisEntry::GroupEnd { level: 0, depth: 0 }, AxisEntry::item(vec!["Sep".into()]), ], "Q1 folds to one funded row under its header, and Mar pruned" @@ -1814,6 +1923,8 @@ mod tests { wb.add_item_in_group("Region", "South", "Lower").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for month in ["Jan", "Feb"] { wb.model.set_cell( @@ -1895,6 +2006,7 @@ mod tests { wb.add_item_in_group("Month", "Apr", "Q2").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for (month, revenue, cost) in [("Jan", 10.0, 5.0), ("Feb", 30.0, 3.0)] { @@ -1937,7 +2049,7 @@ mod tests { .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let mut wb = margin_book(&chain); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); let unpruned = GridLayout::new(&wb.model, wb.active_view()); let kept_by_fresh_evaluation: Vec = (0..unpruned.row_count()) @@ -1976,7 +2088,7 @@ mod tests { .collect(); let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); let mut wb = margin_book(&chain); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); wb.model.recompute_formulas(&wb.active_view().none_cats()); let layout = GridLayout::new(&wb.model, wb.active_view()); let q1 = (0..layout.row_count()) @@ -2055,7 +2167,7 @@ mod tests { #[test] fn a_grid_evaluates_under_its_own_covers_not_another_grids() { let mut wb = margin_book(&["R = Revenue / Cost"]); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); let all = GridLayout::new(&wb.model, wb.active_view()); let mut no_feb_view = wb.active_view().clone(); no_feb_view.hide_item("Month", "Feb"); @@ -2093,7 +2205,7 @@ mod tests { let mut folded = margin_book(formulas); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", "Margin").expect("a Q1 fold"); let mut none = margin_book(formulas); @@ -2120,7 +2232,7 @@ mod tests { let mut folded = margin_book(&formulas); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", measure); let mut none = margin_book(&formulas); none.active_view_mut().set_axis("Month", Axis::None); @@ -2133,7 +2245,7 @@ mod tests { let mut folded = margin_book(&formulas); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); assert_eq!( grid_value(&mut folded, "Q1", "Flag"), Some(0.0), @@ -2146,7 +2258,7 @@ mod tests { #[test] fn a_folds_aggregate_formula_pools_its_members_records() { let mut wb = margin_book(&["Top = MAX(Revenue)", "Total = SUM(Revenue)"]); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); assert_eq!(grid_value(&mut wb, "Q1", "Total"), Some(40.0)); assert_eq!(grid_value(&mut wb, "Q1", "Top"), Some(30.0)); } @@ -2159,7 +2271,7 @@ mod tests { let mut folded = margin_book(&formula); folded .active_view_mut() - .toggle_group_collapse("Month", "Q1"); + .toggle_group_collapse("Month", &["Q1"]); let fold = grid_value(&mut folded, "Q1", "Jans"); let mut none = margin_book(&formula); @@ -2198,12 +2310,13 @@ mod tests { CellValue::Number(n), ); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("Type", Axis::Column); wb.active_view_mut().hide_item("Month", "Feb"); assert_eq!(column_total(&wb), 103.0, "expanded: Jan and Apr are drawn"); - wb.active_view_mut().toggle_group_collapse("Month", "Q1"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q1"]); assert_eq!(column_total(&wb), 103.0, "z moved the total"); assert_eq!( grid_value(&mut wb, "Q1", "Food"), @@ -2290,7 +2403,7 @@ mod group_membership { use std::ops::ControlFlow; /// Every data item's open runs, collected through the one reader. - fn open_runs(items: &[AxisEntry]) -> Vec>> { + fn open_runs(items: &[AxisEntry]) -> Vec> { let mut all = Vec::new(); walk_open_runs(items, |open| { all.push(open.to_vec()); @@ -2300,7 +2413,9 @@ mod group_membership { } type Level<'a> = (&'a str, &'a [(&'a str, Option<&'a str>)]); - type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)]); + type Fixture<'a> = (&'a [Level<'a>], &'a [(&'a str, &'a str)], &'a [Parent<'a>]); + /// `(category, group, parent)`: a `>` line. + type Parent<'a> = (&'a str, &'a str, &'a str); const TWO: &[Level] = &[ ("Region", &[("North", Some("Upper")), ("South", None)]), @@ -2354,19 +2469,80 @@ mod group_membership { ( TWO, &[("Region", "Upper"), ("Month", "Q1"), ("Month", "Q2")], + &[], ), ( THREE, &[("Region", "Upper"), ("Month", "Q1"), ("Kind", "Goods")], + &[], ), - (NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")]), - (HOLE, &[("Region", "Upper"), ("Month", "Q1")]), + (NONCONTIGUOUS, &[("Region", "Upper"), ("Month", "Q1")], &[]), + (HOLE, &[("Region", "Upper"), ("Month", "Q1")], &[]), ( ADJACENT, &[("Region", "Upper"), ("Region", "Lower"), ("Month", "Q1")], + &[], + ), + ( + NESTED, + &[ + ("Region", "Upper"), + ("Month", "H1"), + ("Month", "Q1"), + ("Month", "H2"), + ], + NESTED_PARENTS, ), ]; + /// improvise-felo.6: a hierarchy INSIDE one category, under an outer group. + /// Q1 is non-contiguous, and Jun sits directly in H1 after its child Q2. + const NESTED: &[Level] = &[ + ("Region", &[("North", Some("Upper")), ("South", None)]), + ( + "Month", + &[ + ("Jan", Some("Q1")), + ("Apr", Some("Q2")), + ("Feb", Some("Q1")), + ("Jun", Some("H1")), + ("Jul", Some("Q3")), + ], + ), + ]; + + const NESTED_PARENTS: &[Parent] = &[ + ("Month", "Q1", "H1"), + ("Month", "Q2", "H1"), + ("Month", "Q3", "H2"), + ]; + + /// [`book`] with `parents` declared and every group OPEN before `collapsed` + /// folds its own, so a subset of groups is exactly what is collapsed. + fn nested_book(levels: &[Level], parents: &[Parent], collapsed: &[(&str, &str)]) -> Workbook { + let mut wb = book(levels, &[]); + for (cat, group, parent) in parents { + let c = wb.model.category_mut(cat).unwrap(); + c.add_group(crate::model::category::Group::new(*group).with_parent(*parent)); + } + for (cat, _) in levels { + wb.active_view_mut().set_expand_depth(cat, 8); + } + for (cat, group) in collapsed { + let chain: Vec = wb + .model + .category(cat) + .unwrap() + .group_chain(group) + .into_iter() + .map(String::from) + .collect(); + let chain: Vec<&str> = chain.iter().map(String::as_str).collect(); + wb.active_view_mut().toggle_group_collapse(cat, &chain); + } + wb + } + /// `levels` on Row, one column, the groups `collapsed` names folded. No cell /// is set: the unpruned layout has every row, and pruning is driven directly. fn book(levels: &[Level], collapsed: &[(&str, &str)]) -> Workbook { @@ -2380,12 +2556,13 @@ mod group_membership { }; } wb.active_view_mut().set_axis(cat, Axis::Row); + wb.active_view_mut().set_expand_depth(cat, 1); } wb.add_category("Col").unwrap(); wb.add_item("Col", "X").unwrap(); wb.active_view_mut().set_axis("Col", Axis::Column); for (cat, group) in collapsed { - wb.active_view_mut().toggle_group_collapse(cat, group); + wb.active_view_mut().toggle_group_collapse(cat, &[group]); } wb } @@ -2406,13 +2583,13 @@ mod group_membership { /// non-empty set of kept rows. The layout is the cost, so it is built once. fn cases() -> Vec { let mut out = Vec::new(); - for (fixture, (levels, groups)) in FIXTURES.iter().enumerate() { + for (fixture, (levels, groups, parents)) in FIXTURES.iter().enumerate() { for folds in 0..(1u32 << groups.len()) { let collapsed: Vec<(&str, &str)> = (0..groups.len()) .filter(|g| folds & (1 << g) != 0) .map(|g| groups[g]) .collect(); - let full = layout(&book(levels, &collapsed)).row_items; + let full = layout(&nested_book(levels, parents, &collapsed)).row_items; let rows = data_items(&full).count(); for mask in 1..(1u32 << rows) { let mut pruned = full.clone(); @@ -2441,8 +2618,8 @@ mod group_membership { } } - fn at(runs: &[Option], level: usize) -> Option { - runs.get(level).copied().flatten() + fn at(runs: &[super::OpenRun], key: super::RunKey) -> Option { + runs.iter().find(|(k, _)| *k == key).map(|(_, at)| *at) } #[test] @@ -2463,24 +2640,25 @@ mod group_membership { if kept.len() == full_runs.len() { assert_eq!(pruned, full, "{name}: nothing pruned"); } - let levels = full_runs.iter().map(Vec::len).max().unwrap_or(0); + let levels: std::collections::BTreeSet = + full_runs.iter().flatten().map(|(k, _)| *k).collect(); for (r, &u) in kept.iter().enumerate() { - for level in 0..levels { + for &level in &levels { assert_eq!( named(pruned, at(&pruned_runs[r], level)), named(full, at(&full_runs[u], level)), - "{name}: row {r}, level {level}\n pruned {pruned:?}\n full {full:?}" + "{name}: row {r}, level {level:?}\n pruned {pruned:?}\n full {full:?}" ); if r == 0 { continue; } - let same = |runs: &[Vec>], a: usize, b: usize| { + let same = |runs: &[Vec], a: usize, b: usize| { at(&runs[a], level).is_some() && at(&runs[a], level) == at(&runs[b], level) }; assert_eq!( same(&pruned_runs, r - 1, r), same(&full_runs, kept[r - 1], u), - "{name}: run identity at level {level}, row {r}\n pruned {pruned:?}\n full {full:?}" + "{name}: run identity at level {level:?}, row {r}\n pruned {pruned:?}\n full {full:?}" ); } } @@ -2538,14 +2716,18 @@ mod group_membership { let items = vec![ AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "Region".into(), group_name: "Upper".into(), + collapsed: false, }, AxisEntry::item(vec!["North".into()]), AxisEntry::GroupHeader { level: usize::MAX, + depth: 0, cat_name: "Nowhere".into(), group_name: "Unreachable".into(), + collapsed: false, }, AxisEntry::item(vec!["South".into()]), ]; @@ -2561,14 +2743,16 @@ mod group_membership { fn a_late_lookup_answers_for_its_own_row() { let header = |group: &str| AxisEntry::GroupHeader { level: 0, + depth: 0, cat_name: "Month".into(), group_name: group.into(), + collapsed: false, }; let items = vec![ header("Q1"), AxisEntry::item(vec!["Jan".into()]), AxisEntry::item(vec!["Feb".into()]), - AxisEntry::GroupEnd { level: 0 }, + AxisEntry::GroupEnd { level: 0, depth: 0 }, AxisEntry::item(vec!["Mar".into()]), header("Q2"), AxisEntry::item(vec!["Apr".into()]), @@ -2601,4 +2785,390 @@ mod group_membership { ); assert_eq!(l.row_group_for(at(["South", "Mar"])), None); } + + /// Precondition for the enumeration above: NESTED must really produce runs + /// below depth 0, or it enumerates nothing new. + #[test] + fn the_nested_fixture_draws_runs_inside_one_category() { + let wb = nested_book(NESTED, NESTED_PARENTS, &[]); + let deep = layout(&wb) + .row_items + .iter() + .filter(|e| matches!(e, AxisEntry::GroupHeader { depth: 1, .. })) + .count(); + assert!( + deep >= 4, + "Q1, Q2, Q1 again and Q3 under each region: {deep}" + ); + } +} + +/// improvise-felo.6: a hierarchy inside one category — Year > Quarter > Month. +#[cfg(test)] +mod hierarchy { + use super::{AxisEntry, GridLayout}; + use crate::model::category::Group; + use crate::model::cell::{CellKey, CellValue}; + use crate::view::Axis; + use crate::workbook::Workbook; + + /// `2024-02` is added AFTER `2025-01` and `Undated`, so Q1-2024 and 2024 are + /// non-contiguous. Values are powers of ten, so every fold is its own sum. + fn dates() -> Workbook { + let mut wb = Workbook::new("T"); + wb.add_category("Date").unwrap(); + for (month, quarter) in [ + ("2024-01", Some("Q1-2024")), + ("2024-04", Some("Q2-2024")), + ("2025-01", Some("Q1-2025")), + ("Undated", None), + ("2024-02", Some("Q1-2024")), + ] { + match quarter { + Some(q) => wb.add_item_in_group("Date", month, q).unwrap(), + None => wb.add_item("Date", month).unwrap(), + }; + } + let cat = wb.model.category_mut("Date").unwrap(); + for (quarter, year) in [ + ("Q1-2024", "2024"), + ("Q2-2024", "2024"), + ("Q1-2025", "2025"), + ] { + cat.add_group(Group::new(quarter).with_parent(year)); + } + wb.add_item("_Measure", "Revenue").unwrap(); + wb.active_view_mut().set_axis("Date", Axis::Row); + wb.active_view_mut().set_axis("_Measure", Axis::Column); + for (month, v) in [ + ("2024-01", 1.0), + ("2024-02", 10.0), + ("2024-04", 100.0), + ("2025-01", 1000.0), + ("Undated", 5.0), + ] { + wb.model.set_cell(key(month), CellValue::Number(v)); + } + wb + } + + fn key(month: &str) -> CellKey { + CellKey::new(vec![ + ("Date".into(), month.into()), + ("_Measure".into(), "Revenue".into()), + ]) + } + + fn layout(wb: &Workbook) -> GridLayout { + GridLayout::with_frozen_records(&wb.model, wb.active_view(), None) + } + + fn rows(l: &GridLayout) -> Vec { + (0..l.row_count()).map(|r| l.row_label(r)).collect() + } + + fn value(wb: &Workbook, l: &GridLayout, label: &str) -> Option { + let r = rows(l).iter().position(|x| x == label)?; + l.evaluate(&wb.model, &l.cell_key(r, 0)?)?.as_f64() + } + + fn header(depth: usize, group: &str, collapsed: bool) -> AxisEntry { + AxisEntry::GroupHeader { + level: 0, + depth, + cat_name: "Date".into(), + group_name: group.into(), + collapsed, + } + } + + fn end(depth: usize) -> AxisEntry { + AxisEntry::GroupEnd { level: 0, depth } + } + + fn item(name: &str) -> AxisEntry { + AxisEntry::item(vec![name.into()]) + } + + /// Opened to depth 2, the whole sequence: runs nest by depth, a new year's + /// header replaces the old, and a non-contiguous quarter reopens both. + #[test] + fn a_year_quarter_month_hierarchy_nests_its_runs() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 2); + assert_eq!( + layout(&wb).row_items, + vec![ + header(0, "2024", false), + header(1, "Q1-2024", false), + item("2024-01"), + header(1, "Q2-2024", false), + item("2024-04"), + end(1), + header(0, "2025", false), + header(1, "Q1-2025", false), + item("2025-01"), + end(1), + end(0), + item("Undated"), + header(0, "2024", false), + header(1, "Q1-2024", false), + item("2024-02"), + ] + ); + } + + /// The user's ruling: a hierarchy starts closed, one row per root. + #[test] + fn a_hierarchy_opens_closed_as_one_fold_per_root() { + let wb = dates(); + let l = layout(&wb); + assert_eq!(rows(&l), vec!["2024", "2025", "Undated"]); + assert_eq!(value(&wb, &l, "2024"), Some(111.0), "every month of 2024"); + assert_eq!(value(&wb, &l, "2025"), Some(1000.0)); + assert!(l.row_folds_a_group(0) && !l.row_folds_a_group(2)); + } + + /// Open the years only: each quarter folds its months, ONCE, even when its + /// months are not contiguous. + #[test] + fn a_collapsed_group_at_an_inner_level_folds_its_leaves_once() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 1); + let l = layout(&wb); + assert_eq!(rows(&l), vec!["Q1-2024", "Q2-2024", "Q1-2025", "Undated"]); + assert_eq!( + value(&wb, &l, "Q1-2024"), + Some(11.0), + "Jan and the late Feb" + ); + assert_eq!(value(&wb, &l, "Q2-2024"), Some(100.0)); + } + + /// Collapsing an outer group folds everything under it, whatever its + /// children's own state. + #[test] + fn collapsing_a_parent_folds_its_children_whatever_their_state() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 2); + wb.active_view_mut() + .toggle_group_collapse("Date", &["2024"]); + let l = layout(&wb); + assert_eq!(rows(&l), vec!["2024", "2025-01", "Undated"]); + assert_eq!(value(&wb, &l, "2024"), Some(111.0)); + } + + /// `z` names the innermost open group: a month's quarter, a quarter fold's + /// own quarter, a year fold's year — at a nonzero row each time. + #[test] + fn the_group_under_the_cursor_is_the_innermost_at_every_depth() { + let mut wb = dates(); + wb.active_view_mut().set_expand_depth("Date", 1); + wb.active_view_mut() + .toggle_group_collapse("Date", &["2024", "Q2-2024"]); + let l = layout(&wb); + let at = |label: &str| rows(&l).iter().position(|x| x == label).unwrap(); + let g = |name: &str| Some(("Date".to_string(), name.to_string())); + assert_eq!(l.row_group_for(at("2024-04")), g("Q2-2024")); + assert_eq!(l.row_group_for(at("Q1-2025")), g("Q1-2025")); + assert_eq!(l.row_group_for(at("Undated")), None); + wb.active_view_mut().set_expand_depth("Date", 0); + let l = layout(&wb); + assert_eq!(l.row_group_for(1), g("2025")); + } + + /// A hidden month leaves every fold above it; a quarter whose every month + /// is hidden has no row and no header. + #[test] + fn hidden_items_leave_every_fold_above_them() { + let mut wb = dates(); + wb.active_view_mut().hide_item("Date", "2024-04"); + let l = layout(&wb); + assert_eq!(value(&wb, &l, "2024"), Some(11.0), "2024-04 hidden"); + wb.active_view_mut().set_expand_depth("Date", 2); + let l = layout(&wb); + let q2 = |e: &AxisEntry| matches!(e, AxisEntry::GroupHeader { group_name, .. } if group_name == "Q2-2024"); + assert!(!l.row_items.iter().any(q2), "{:?}", l.row_items); + } + + /// `prune_empty` at depth: an empty quarter goes, header and all, and the + /// year above it stays for the quarter that has data. + #[test] + fn pruning_drops_an_empty_inner_group_and_keeps_its_parent() { + let mut wb = dates(); + wb.model.clear_cell(&key("2024-04")); + wb.active_view_mut().set_expand_depth("Date", 1); + wb.active_view_mut().prune_empty = true; + let l = layout(&wb); + assert_eq!(rows(&l), vec!["Q1-2024", "Q1-2025", "Undated"]); + assert_eq!(l.row_items[0], header(0, "2024", false)); + } +} + +#[cfg(test)] +mod lookup_cost_tests { + use super::{DATA_ITEM_STEPS, GridLayout}; + use crate::view::Axis; + use crate::workbook::Workbook; + + fn tall(rows: usize) -> GridLayout { + let mut wb = Workbook::new("T"); + wb.add_category("Row").unwrap(); + wb.add_category("Col").unwrap(); + for i in 0..rows { + wb.add_item("Row", format!("r{i}")).unwrap(); + } + wb.add_item("Col", "c0").unwrap(); + wb.add_item("Col", "c1").unwrap(); + wb.active_view_mut().set_axis("Row", Axis::Row); + wb.active_view_mut().set_axis("Col", Axis::Column); + GridLayout::new(&wb.model, wb.active_view()) + } + + fn steps(f: impl FnOnce()) -> usize { + DATA_ITEM_STEPS.with(|n| n.set(0)); + f(); + DATA_ITEM_STEPS.with(|n| n.get()) + } + + /// improvise-felo.13: context/test-evidence/layout-cost.md. + #[test] + fn an_axis_lookup_does_work_independent_of_the_row_index() { + let layout = tall(400); + let last = layout.row_count() - 1; + assert_eq!(last, 399, "the fixture is as tall as the test needs"); + let lookups: [(&str, &dyn Fn(usize)); 4] = [ + ("cell_key", &|r| { + let _ = layout.cell_key(r, 1); + }), + ("row_tuple", &|r| { + let _ = layout.row_tuple(r); + }), + ("row_label", &|r| { + let _ = layout.row_label(r); + }), + ("column_key", &|r| { + let _ = layout.column_key(r % 2); + }), + ]; + for (name, lookup) in lookups { + let first = steps(|| lookup(0)); + let deep = steps(|| lookup(last)); + assert!( + deep <= first + 2, + "{name}: row {last} cost {deep} steps against {first} at row 0" + ); + } + } + + /// improvise-felo.13: the index answers what the walk it replaced did, through + /// groups, a collapsed fold and a pruned axis. Evidence: layout-cost.md. + #[test] + fn the_position_index_answers_what_the_walk_answered() { + use super::{AxisEntry, data_items}; + use crate::model::cell::{CellKey, CellValue}; + let mut wb = Workbook::new("T"); + for cat in ["Row", "Col"] { + wb.add_category(cat).unwrap(); + } + for (i, g) in ["Q1", "Q1", "Q2", "Q2", "Q3"].iter().enumerate() { + wb.add_item_in_group("Row", format!("r{i}"), *g).unwrap(); + } + wb.add_item("Row", "ghost").unwrap(); + wb.add_item("Col", "c0").unwrap(); + wb.active_view_mut().set_axis("Row", Axis::Row); + wb.active_view_mut().set_axis("Col", Axis::Column); + wb.active_view_mut().set_expand_depth("Row", 1); + wb.active_view_mut().toggle_group_collapse("Row", &["Q2"]); + wb.active_view_mut().prune_empty = true; + for i in 0..5 { + let key = CellKey::new(vec![ + ("Row".into(), format!("r{i}")), + ("Col".into(), "c0".into()), + ]); + wb.model.set_cell(key, CellValue::Number(1.0)); + } + let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); + assert!( + layout + .row_items + .iter() + .any(|e| !matches!(e, AxisEntry::DataItem { .. })) + ); + assert!(layout.row_items.iter().any(|e| matches!( + e, + AxisEntry::DataItem { + collapsed: true, + .. + } + ))); + let walked: Vec<&Vec> = data_items(&layout.row_items).collect(); + assert_eq!(layout.row_count(), walked.len()); + let visual: Vec = layout + .row_items + .iter() + .enumerate() + .filter(|(_, e)| matches!(e, AxisEntry::DataItem { .. })) + .map(|(at, _)| at) + .collect(); + for i in 0..walked.len() + 2 { + assert_eq!( + layout.row_tuple(i), + walked.get(i).map(|t| t.as_slice()), + "row {i}" + ); + assert_eq!( + layout.data_row_to_visual(i), + visual.get(i).copied(), + "visual {i}" + ); + let folded = matches!( + data_items_entry(&layout.row_items, i), + Some(AxisEntry::DataItem { + collapsed: true, + .. + }) + ); + assert_eq!(layout.row_folds_a_group(i), folded, "fold {i}"); + } + } + + /// improvise-felo.13: a drill held open while records are removed elsewhere. + /// Evidence: layout-cost.md. + #[test] + fn a_frozen_records_snapshot_replaces_the_live_row_index() { + use crate::model::cell::{CellKey, CellValue}; + let mut wb = Workbook::new("T"); + wb.add_category("Region").unwrap(); + wb.add_item("_Measure", "Revenue").unwrap(); + let cell = |i: usize| { + CellKey::new(vec![ + ("Region".into(), format!("r{i}")), + ("_Measure".into(), "Revenue".into()), + ]) + }; + for i in 0..3 { + wb.add_item("Region", format!("r{i}")).unwrap(); + wb.model.set_cell(cell(i), CellValue::Number(1.0)); + } + let v = wb.active_view_mut(); + v.set_axis("_Index", Axis::Row); + v.set_axis("_Dim", Axis::Column); + let frozen = GridLayout::new(&wb.model, wb.active_view()).records; + wb.model.clear_cell(&cell(1)); + wb.model.clear_cell(&cell(2)); + let live = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); + assert_eq!(live.row_count(), 1, "the control: one record is live"); + let held = GridLayout::with_frozen_records(&wb.model, wb.active_view(), frozen); + assert_eq!(held.row_count(), 3); + assert_eq!(held.row_tuple(2), Some(&["2".to_string()][..])); + assert!(held.cell_key(2, 0).is_some()); + } + + fn data_items_entry(items: &[super::AxisEntry], n: usize) -> Option<&super::AxisEntry> { + items + .iter() + .filter(|e| matches!(e, super::AxisEntry::DataItem { .. })) + .nth(n) + } } diff --git a/crates/improvise-core/src/view/mod.rs b/crates/improvise-core/src/view/mod.rs index fe12c4aa..e7466938 100644 --- a/crates/improvise-core/src/view/mod.rs +++ b/crates/improvise-core/src/view/mod.rs @@ -2,12 +2,12 @@ pub mod axis; pub mod layout; pub mod types; -pub use axis::{Axis, AxisColor}; +pub use axis::Axis; pub use layout::{ AxisEntry, GridEval, GridLayout, NEW_CATEGORY_COL, NEW_MEASURE_COL, RecordCol, RecordRow, ViewStep, drill_constraint, drill_snapshot, drill_steps, record_row, records_spine, records_steps, synthetic_record_info, }; pub use types::{ - DEFAULT_VIEW, DRILL_VIEW, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name, + DEFAULT_VIEW, DRILL_VIEW, GroupFold, RECORDS_VIEW, VIRTUAL_VIEW_NAMES, View, unused_view_name, }; diff --git a/crates/improvise-core/src/view/types.rs b/crates/improvise-core/src/view/types.rs index e9b91961..72d8205f 100644 --- a/crates/improvise-core/src/view/types.rs +++ b/crates/improvise-core/src/view/types.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use std::collections::{HashMap, HashSet}; +use std::num::NonZeroUsize; use super::axis::Axis; use crate::model::category::CategoryKind; @@ -27,17 +28,32 @@ pub fn unused_view_name<'a>(existing: impl IntoIterator) -> Stri .expect("a name is free below usize::MAX, there being fewer views") } +/// One group's own fold, overriding what it inherits (core.md §19). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum GroupFold { + /// `collapsed: Cat/Group` — closed, whatever reaches it from above. + Collapsed, + /// `expanded(N): Cat/Group` — open, with N-1 levels beneath it. Non-zero by + /// type, since `expanded(0)` is no spelling the grammar reads back. + Expanded(NonZeroUsize), +} + #[derive(Clone)] pub struct View { pub name: String, + /// What a person sees; the key until a rename (ADR 007 clause 2). + display: String, /// Axis assignment for each category pub category_axes: IndexMap, /// For page axis: selected item per category pub page_selections: HashMap, /// Hidden items per category pub hidden_items: HashMap>, - /// Collapsed groups per category - pub collapsed_groups: HashMap>, + /// How deep each category's hierarchy opens: groups shallower than this start + /// open, the rest closed. Absent is 0 — everything closed (core.md §19). + pub expand_depth: HashMap, + /// Per-group answers that override the depth default, in either direction. + pub group_folds: HashMap>, /// Number format string (e.g. ",.0f" for comma-separated integer) pub number_format: String, /// When true, empty rows/columns are pruned from the display. @@ -51,10 +67,12 @@ impl std::fmt::Debug for View { use std::collections::{BTreeMap, BTreeSet}; let View { name, + display, category_axes, page_selections, hidden_items, - collapsed_groups, + expand_depth, + group_folds, number_format, prune_empty, } = self; @@ -64,12 +82,19 @@ impl std::fmt::Debug for View { .collect() }; let pages: BTreeMap<_, _> = page_selections.iter().collect(); + let depths: BTreeMap<_, _> = expand_depth.iter().collect(); + let folds: BTreeMap<_, BTreeMap<_, _>> = group_folds + .iter() + .map(|(k, v)| (k, v.iter().collect())) + .collect(); f.debug_struct("View") .field("name", name) + .field("display", display) .field("category_axes", category_axes) .field("page_selections", &pages) .field("hidden_items", &sorted(hidden_items)) - .field("collapsed_groups", &sorted(collapsed_groups)) + .field("expand_depth", &depths) + .field("group_folds", &folds) .field("number_format", number_format) .field("prune_empty", prune_empty) .finish() @@ -82,13 +107,25 @@ impl View { VIRTUAL_VIEW_NAMES.contains(&self.name.as_str()) } + pub fn display(&self) -> &str { + &self.display + } + + /// Changes what a person sees and nothing else: the key does not move. + pub fn set_display(&mut self, display: impl Into) { + self.display = display.into(); + } + pub fn new(name: impl Into) -> Self { + let name = name.into(); Self { - name: name.into(), + display: name.clone(), + name, category_axes: IndexMap::new(), page_selections: HashMap::new(), hidden_items: HashMap::new(), - collapsed_groups: HashMap::new(), + expand_depth: HashMap::new(), + group_folds: HashMap::new(), // Decimals by default: a ratio is the case a format cannot be // guessed for, and `.0` draws 2/3 as `1` (improvise-e0ex). number_format: ",.2".to_string(), @@ -135,7 +172,8 @@ impl View { self.category_axes.shift_remove(cat_name); self.page_selections.remove(cat_name); self.hidden_items.remove(cat_name); - self.collapsed_groups.remove(cat_name); + self.expand_depth.remove(cat_name); + self.group_folds.remove(cat_name); } /// Forget an item this view names. A hidden or paged-to item that outlives @@ -222,23 +260,78 @@ impl View { self.page_selections.get(cat_name).map(|s| s.as_str()) } - pub fn toggle_group_collapse(&mut self, cat_name: &str, group_name: &str) { - let set = self - .collapsed_groups - .entry(cat_name.to_string()) - .or_default(); - if set.contains(group_name) { - set.remove(group_name); - } else { - set.insert(group_name.to_string()); + /// How many of `cat_name`'s top levels open (`expanded(N): Cat`); 0 — all + /// closed — if unset. + pub fn expand_depth(&self, cat_name: &str) -> usize { + self.expand_depth.get(cat_name).copied().unwrap_or(0) + } + + /// Open `cat_name`'s top `depth` levels; 0 forgets the setting. + pub fn set_expand_depth(&mut self, cat_name: &str, depth: usize) { + match depth { + 0 => self.expand_depth.remove(cat_name), + _ => self.expand_depth.insert(cat_name.to_string(), depth), + }; + } + + /// Whether the group ending `chain` (root first, from `Category::group_chain`) + /// is collapsed: its own fold wins, else what it inherits (core.md §19). + pub fn is_group_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool { + match chain.last().and_then(|g| self.group_fold(cat_name, g)) { + Some(GroupFold::Collapsed) => true, + Some(GroupFold::Expanded(_)) => false, + None => self.inherits_collapsed(cat_name, chain), } } - pub fn is_group_collapsed(&self, cat_name: &str, group_name: &str) -> bool { - self.collapsed_groups - .get(cat_name) - .map(|s| s.contains(group_name)) - .unwrap_or(false) + /// The answer without the group's own fold: open when the category's depth or + /// an ancestor's `expanded(N)` reaches it, closed otherwise. + fn inherits_collapsed(&self, cat_name: &str, chain: &[&str]) -> bool { + let depth = chain.len().saturating_sub(1); + let by_ancestor = chain[..depth].iter().enumerate().any(|(at, ancestor)| { + matches!(self.group_fold(cat_name, ancestor), + Some(GroupFold::Expanded(n)) if depth - at < n.get()) + }); + !by_ancestor && depth >= self.expand_depth(cat_name) + } + + /// The fold this view holds for one group, if any. + pub fn group_fold(&self, cat_name: &str, group_name: &str) -> Option { + self.group_folds.get(cat_name)?.get(group_name).copied() + } + + /// Record a fold as given — what a file's `collapsed:`/`expanded:` says. + pub fn set_group_fold(&mut self, cat_name: &str, group_name: &str, fold: GroupFold) { + self.group_folds + .entry(cat_name.to_string()) + .or_default() + .insert(group_name.to_string(), fold); + } + + /// Flip the group ending `chain`: `z`. It writes `Collapsed` or `Expanded(1)` + /// — or nothing, where the flip lands on what the group inherits (§19). + pub fn toggle_group_collapse(&mut self, cat_name: &str, chain: &[&str]) { + let Some(group) = chain.last() else { + return; + }; + let collapse = !self.is_group_collapsed(cat_name, chain); + match collapse == self.inherits_collapsed(cat_name, chain) { + true => { + if let Some(folds) = self.group_folds.get_mut(cat_name) { + folds.remove(*group); + if folds.is_empty() { + self.group_folds.remove(cat_name); + } + } + } + false => { + let fold = match collapse { + true => GroupFold::Collapsed, + false => GroupFold::Expanded(NonZeroUsize::MIN), + }; + self.set_group_fold(cat_name, group, fold); + } + } } pub fn hide_item(&mut self, cat_name: &str, item_name: &str) { @@ -292,9 +385,18 @@ impl View { #[cfg(test)] mod tests { - use super::{View, unused_view_name}; + use super::{GroupFold, View, unused_view_name}; use crate::model::category::CategoryKind; use crate::view::Axis; + use std::num::NonZeroUsize; + + #[test] + fn a_view_display_starts_as_the_key_and_changing_it_moves_no_key() { + let mut v = View::new("Default"); + assert_eq!(v.display(), "Default"); + v.set_display("Overview"); + assert_eq!((v.name.as_str(), v.display()), ("Default", "Overview")); + } /// improvise-vw47: the count is the starting guess, never the answer. /// What a taken name costs: `context/test-evidence/core.md`. @@ -563,28 +665,87 @@ mod tests { assert_eq!(v.page_selection("Region"), None); } + /// The user's ruling on improvise-felo.6: every group starts closed. #[test] - fn toggle_group_collapse_toggles_twice() { + fn a_group_starts_collapsed_at_every_depth() { + let v = View::new("Test"); + assert!(v.is_group_collapsed("Time", &["2024"])); + assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"])); + } + + /// `expanded(1): Time` opens the roots and nothing below them. + #[test] + fn a_category_depth_opens_its_top_levels() { let mut v = View::new("Test"); - assert!(!v.is_group_collapsed("Time", "Q1")); - v.toggle_group_collapse("Time", "Q1"); - assert!(v.is_group_collapsed("Time", "Q1")); - v.toggle_group_collapse("Time", "Q1"); - assert!(!v.is_group_collapsed("Time", "Q1")); + v.set_expand_depth("Time", 1); + assert!(!v.is_group_collapsed("Time", &["2024"])); + assert!(v.is_group_collapsed("Time", &["2024", "Q1-2024"])); + assert!(v.is_group_collapsed("Other", &["G"]), "per category"); + v.set_expand_depth("Time", 0); + assert!(v.expand_depth.is_empty(), "0 is the absence, not a setting"); + } + + /// `expanded(2): Time/2024` opens 2024 and one level beneath it, no deeper, + /// and nothing of the sibling year. + #[test] + fn a_groups_depth_opens_it_and_n_minus_one_levels_beneath() { + let mut v = View::new("Test"); + v.set_group_fold( + "Time", + "2024", + GroupFold::Expanded(NonZeroUsize::new(2).unwrap()), + ); + assert!(!v.is_group_collapsed("Time", &["2024"])); + assert!(!v.is_group_collapsed("Time", &["2024", "Q1"])); + assert!(v.is_group_collapsed("Time", &["2024", "Q1", "Jan"])); + assert!(v.is_group_collapsed("Time", &["2025", "Q1"])); + } + + /// A group's own `collapsed:` wins over an `expanded(N)` that reaches it. + #[test] + fn a_collapsed_group_inside_an_expanded_region_stays_collapsed() { + let mut v = View::new("Test"); + v.set_expand_depth("Time", 3); + v.set_group_fold("Time", "Q1", GroupFold::Collapsed); + assert!(v.is_group_collapsed("Time", &["2024", "Q1"])); + assert!(!v.is_group_collapsed("Time", &["2024", "Q2"])); + } + + /// `z` writes `Expanded(1)` or `Collapsed`, in BOTH directions, and + /// toggling back leaves no fold behind. + #[test] + fn a_toggle_overrides_what_a_group_inherits_either_way_and_toggling_back_clears_it() { + let mut v = View::new("Test"); + v.set_expand_depth("Time", 1); + v.toggle_group_collapse("Time", &["2024", "Q1-2024"]); + assert_eq!( + v.group_fold("Time", "Q1-2024"), + Some(GroupFold::Expanded(NonZeroUsize::MIN)) + ); + v.toggle_group_collapse("Time", &["2024"]); + assert_eq!(v.group_fold("Time", "2024"), Some(GroupFold::Collapsed)); + assert!( + !v.is_group_collapsed("Time", &["2025"]), + "a sibling is untouched" + ); + v.toggle_group_collapse("Time", &["2024", "Q1-2024"]); + v.toggle_group_collapse("Time", &["2024"]); + assert!(v.group_folds.is_empty(), "{:?}", v.group_folds); } #[test] fn is_group_collapsed_isolated_across_categories() { let mut v = View::new("Test"); - v.toggle_group_collapse("Cat1", "G1"); - assert!(!v.is_group_collapsed("Cat2", "G1")); + v.toggle_group_collapse("Cat1", &["G1"]); + assert!(!v.is_group_collapsed("Cat1", &["G1"])); + assert!(v.is_group_collapsed("Cat2", &["G1"])); } #[test] fn is_group_collapsed_isolated_across_groups() { let mut v = View::new("Test"); - v.toggle_group_collapse("Cat1", "G1"); - assert!(!v.is_group_collapsed("Cat1", "G2")); + v.toggle_group_collapse("Cat1", &["G1"]); + assert!(v.is_group_collapsed("Cat1", &["G2"])); } /// Arm 2 of improvise-qx1z: the other three lines of `on_category_removed` @@ -593,12 +754,13 @@ mod tests { fn on_category_removed_forgets_a_collapsed_group() { let mut v = View::new("V"); v.on_category_added("Month", CategoryKind::of_name("Month")); - v.toggle_group_collapse("Month", "Q1"); + v.set_expand_depth("Month", 1); + v.toggle_group_collapse("Month", &["Q1"]); v.on_category_removed("Month"); v.on_category_added("Month", CategoryKind::of_name("Month")); assert!( - !v.is_group_collapsed("Month", "Q1"), - "a re-added category came back with its group collapsed" + v.group_folds.is_empty() && v.expand_depth.is_empty(), + "a re-added category came back with its old fold state" ); } @@ -808,10 +970,10 @@ mod prop_tests { group in "[A-Za-z][a-z]{1,7}", ) { let mut v = View::new("T"); - let initial = v.is_group_collapsed(&cat, &group); - v.toggle_group_collapse(&cat, &group); - v.toggle_group_collapse(&cat, &group); - prop_assert_eq!(v.is_group_collapsed(&cat, &group), initial); + let initial = v.is_group_collapsed(&cat, &[group.as_str()]); + v.toggle_group_collapse(&cat, &[group.as_str()]); + v.toggle_group_collapse(&cat, &[group.as_str()]); + prop_assert_eq!(v.is_group_collapsed(&cat, &[group.as_str()]), initial); } } } diff --git a/crates/improvise-io/src/import/analyzer.rs b/crates/improvise-io/src/import/analyzer.rs index d51dac1f..69a39c55 100644 --- a/crates/improvise-io/src/import/analyzer.rs +++ b/crates/improvise-io/src/import/analyzer.rs @@ -22,6 +22,26 @@ pub enum DateComponent { Quarter, } +impl DateComponent { + /// The components as a hierarchy nests them, coarsest first. + pub const COARSE_TO_FINE: [DateComponent; 3] = [ + DateComponent::Year, + DateComponent::Quarter, + DateComponent::Month, + ]; +} + +/// How a date column's components become categories (improvise-8jk6.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DateShape { + /// ONE category named after the column: its items are the finest chosen + /// component, grouped by the coarser ones. The user's default. + #[default] + Hierarchy, + /// The column's raw values, plus one flat category per chosen component. + Split, +} + #[derive(Debug, Clone)] pub struct FieldProposal { pub field: String, @@ -32,6 +52,8 @@ pub struct FieldProposal { pub date_format: Option, /// Which date components to extract as new categories. pub date_components: Vec, + /// Whether those components nest in one category or split into several. + pub date_shape: DateShape, } impl FieldProposal { @@ -132,6 +154,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { accepted: true, date_format: None, date_components: vec![], + date_shape: DateShape::default(), }; } @@ -159,6 +182,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { accepted: true, date_format, date_components: vec![], + date_shape: DateShape::default(), }; } @@ -170,6 +194,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { accepted: true, date_format: None, date_components: vec![], + date_shape: DateShape::default(), }; } @@ -180,6 +205,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { accepted: true, date_format: None, date_components: vec![], + date_shape: DateShape::default(), }; } @@ -191,6 +217,7 @@ pub fn analyze_records(records: &[Value]) -> Vec { accepted: true, date_format: None, date_components: vec![], + date_shape: DateShape::default(), } }) .collect() diff --git a/crates/improvise-io/src/import/csv_parser.rs b/crates/improvise-io/src/import/csv_parser.rs index 0075740b..3a7cadfc 100644 --- a/crates/improvise-io/src/import/csv_parser.rs +++ b/crates/improvise-io/src/import/csv_parser.rs @@ -1,18 +1,38 @@ use std::collections::HashSet; -use std::path::Path; +use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, ensure}; use csv::ReaderBuilder; use serde_json::Value; +use crate::storage::{PhysicalFs, Storage}; + +/// Parsed CSV records. Every value is its source text, so they reach an import +/// only through `ImportPipeline::from_csv` (improvise-8jk6.4). +#[derive(Debug, Clone, Default, PartialEq)] +pub struct CsvRecords(Vec); + +impl std::ops::Deref for CsvRecords { + type Target = [Value]; + fn deref(&self) -> &[Value] { + &self.0 + } +} + +impl CsvRecords { + pub(crate) fn into_values(self) -> Vec { + self.0 + } +} + pub fn csv_path_p(path: &Path) -> bool { path.extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("csv")) } -/// Parse a CSV file into a `serde_json::Value` array. Short-row warnings go to -/// stderr; [`parse_csv_with_warnings`] captures them instead. -pub fn parse_csv(path: &Path) -> Result> { +/// Parse a CSV file into records. Short-row warnings go to stderr; +/// [`parse_csv_with_warnings`] captures them instead. +pub fn parse_csv(path: &Path) -> Result { let (records, warnings) = parse_csv_with_warnings(path)?; for warning in &warnings { eprintln!("warning: {warning}"); @@ -22,7 +42,7 @@ pub fn parse_csv(path: &Path) -> Result> { /// Parse a CSV file, returning records plus warnings. A short row keeps every /// header column, missing ones `Null`, each reported with a row count. -pub fn parse_csv_with_warnings(path: &Path) -> Result<(Vec, Vec)> { +pub fn parse_csv_with_warnings(path: &Path) -> Result<(CsvRecords, Vec)> { let bytes = std::fs::read(path) .with_context(|| format!("Failed to open CSV file: {}", path.display()))?; parse_csv_bytes_with_warnings(&bytes) @@ -30,7 +50,13 @@ pub fn parse_csv_with_warnings(path: &Path) -> Result<(Vec, Vec)> /// As [`parse_csv_with_warnings`] but over bytes, so import can come from any /// [`Storage`](crate::storage::Storage) backend, not only a file (improvise-fuj). -pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(Vec, Vec)> { +pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(CsvRecords, Vec)> { + let (_, records, warnings) = parse_csv_bytes(bytes)?; + Ok((CsvRecords(records), warnings)) +} + +/// The header (after renaming), the records and the warnings. +fn parse_csv_bytes(bytes: &[u8]) -> Result<(Vec, Vec, Vec)> { let mut reader = ReaderBuilder::new() .has_headers(true) .flexible(true) @@ -110,7 +136,7 @@ pub fn parse_csv_bytes_with_warnings(bytes: &[u8]) -> Result<(Vec, Vec) -> (Vec, Vec) { /// Parse multiple CSV files and merge into a single JSON array. /// Each record gets a "File" field set to the filename stem (e.g., "sales" from "sales.csv"). -pub fn merge_csvs(paths: &[impl AsRef]) -> Result> { - let mut all_records = Vec::new(); +pub fn merge_csvs(paths: &[impl AsRef]) -> Result { + let merged = merge_csvs_from(&PhysicalFs, paths)?; + for warning in &merged.warnings { + eprintln!("warning: {warning}"); + } + Ok(merged.records) +} + +/// The category every merged record names its source file by. +pub const FILE_CATEGORY: &str = "File"; + +/// One merged file: its `File` item and the columns its header named. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SourceFile { + pub item: String, + pub columns: Vec, +} + +/// Several CSV files as one record set, with each file's own columns kept. +#[derive(Debug, Clone, PartialEq)] +pub struct Merged { + pub records: CsvRecords, + /// One per distinct `File` item, in the order the files were read. + pub sources: Vec, + /// Each file's parse warnings, prefixed with its path. + pub warnings: Vec, +} + +/// As [`merge_csvs`] over any [`Storage`]. Files may have different headers, +/// and two files with one stem share its item and its columns (improvise-lpzu). +pub fn merge_csvs_from(fs: &dyn Storage, paths: &[impl AsRef]) -> Result { + let mut merged = Merged { + records: CsvRecords::default(), + sources: Vec::new(), + warnings: Vec::new(), + }; for path in paths { let path = path.as_ref(); let stem = path @@ -153,37 +213,104 @@ pub fn merge_csvs(paths: &[impl AsRef]) -> Result> { .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); - let records = parse_csv(path)?; + let bytes = fs.read(path)?; + let (headers, records, warnings) = parse_csv_bytes(&bytes) + .with_context(|| format!("Failed to parse CSV file: {}", path.display()))?; + let at = merged.source_for(&stem); + let columns = &mut merged.sources[at].columns; + for header in headers { + if header != FILE_CATEGORY && !columns.contains(&header) { + columns.push(header); + } + } + merged + .warnings + .extend(warnings.iter().map(|w| format!("{}: {w}", path.display()))); for mut record in records { if let Value::Object(ref mut map) = record { - map.insert("File".to_string(), Value::String(stem.clone())); + map.insert(FILE_CATEGORY.to_string(), Value::String(stem.clone())); } - all_records.push(record); + merged.records.0.push(record); } } - Ok(all_records) + Ok(merged) } +impl Merged { + fn source_for(&mut self, item: &str) -> usize { + match self.sources.iter().position(|s| s.item == item) { + Some(at) => at, + None => { + self.sources.push(SourceFile { + item: item.to_string(), + columns: vec![], + }); + self.sources.len() - 1 + } + } + } +} + +/// One `improvise import` argument. Whether a path names a folder is the +/// host's question, so the caller classifies and [`expand_import_paths`] lists. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImportPath { + File(PathBuf), + Folder(PathBuf), +} + +/// The files an import reads: each argument in turn, a folder standing for its +/// `*.csv` documents in name order. See `context/code/formula-and-io.md`. +pub fn expand_import_paths(fs: &dyn Storage, args: &[ImportPath]) -> Result> { + let mut paths = Vec::new(); + for arg in args { + match arg { + ImportPath::File(path) => paths.push(path.clone()), + ImportPath::Folder(dir) => paths.extend(csv_documents_in(fs, dir)?), + } + } + Ok(paths) +} + +/// A folder's `*.csv` documents, sorted by `Storage::list`'s contract. A folder +/// with none is refused by name rather than imported as nothing. +fn csv_documents_in(fs: &dyn Storage, dir: &Path) -> Result> { + let csvs: Vec = fs + .list(dir)? + .into_iter() + .filter(|p| csv_path_p(p)) + .collect(); + ensure!(!csvs.is_empty(), "no CSV files in folder {}", dir.display()); + Ok(csvs) +} + +/// A field is its source text: which ones are numbers is decided by the role +/// the import gives the column (improvise-8jk6.4, `context/code/formula-and-io.md`). fn parse_csv_field(field: &str) -> Value { - if field.is_empty() { - return Value::Null; + match field.is_empty() { + true => Value::Null, + false => Value::String(field.to_string()), } +} - // Try to parse as number (integer or float) - if let Ok(num) = field.parse::() { - return Value::Number(serde_json::Number::from(num)); +/// The number a CSV field's text reads as, where a number is wanted. +pub fn number_in(text: &str) -> Option { + if let Ok(num) = text.parse::() { + return Some(serde_json::Number::from(num)); } - // NaN and the infinities have no JSON number. A fabricated 0 is an invented - // measurement (improvise-jc95), so the token falls through and stays text. - if let Ok(num) = field.parse::() - && let Some(number) = serde_json::Number::from_f64(num) - { - return Value::Number(number); - } + // measurement (improvise-jc95), so the token stays text. + text.parse::() + .ok() + .and_then(serde_json::Number::from_f64) +} - // Otherwise treat as string - Value::String(field.to_string()) +/// A CSV value as a typed JSON value: numeric text becomes its number. +pub fn typed(value: &Value) -> Value { + match value.as_str().and_then(number_in) { + Some(number) => Value::Number(number), + None => value.clone(), + } } #[cfg(test)] @@ -208,8 +335,9 @@ mod tests { assert_eq!(records.len(), 2); assert_eq!(records[0]["Region"], Value::String("East".to_string())); assert_eq!(records[0]["Product"], Value::String("Shirts".to_string())); + assert_eq!(records[0]["Revenue"], Value::String("1000".to_string())); assert_eq!( - records[0]["Revenue"], + typed(&records[0]["Revenue"]), Value::Number(serde_json::Number::from(1000)) ); } @@ -221,9 +349,10 @@ mod tests { let records = parse_csv(&path).unwrap(); assert_eq!(records.len(), 2); - assert!(records[0]["Revenue"].is_f64()); + assert_eq!(records[0]["Revenue"], Value::String("1000.50".to_string())); + assert!(typed(&records[0]["Revenue"]).is_f64()); assert_eq!( - records[0]["Revenue"], + typed(&records[0]["Revenue"]), Value::Number(serde_json::Number::from_f64(1000.50).unwrap()) ); } @@ -261,10 +390,10 @@ mod tests { assert_eq!(records.len(), 2); assert_eq!(records[0]["Name"], Value::String("Widget".to_string())); assert_eq!( - records[0]["Count"], + typed(&records[0]["Count"]), Value::Number(serde_json::Number::from(5)) ); - assert!(records[0]["Price"].is_f64()); + assert!(typed(&records[0]["Price"]).is_f64()); assert_eq!(records[0]["Active"], Value::String("true".to_string())); } @@ -284,10 +413,7 @@ mod tests { assert_eq!(records[3]["File"], Value::String("expenses".to_string())); // Original fields preserved assert_eq!(records[0]["Region"], Value::String("East".to_string())); - assert_eq!( - records[2]["Revenue"], - Value::Number(serde_json::Number::from(50)) - ); + assert_eq!(records[2]["Revenue"], Value::String("50".to_string())); } #[test] @@ -302,6 +428,118 @@ mod tests { assert_eq!(records[0]["Name"], Value::String("A".to_string())); } + // ── Folder arguments (improvise-lpzu) ───────────────────────────── + + use crate::storage::MemoryFs; + + fn folder(files: &[(&str, &str)]) -> MemoryFs { + let fs = MemoryFs::new(); + for (path, body) in files { + fs.write(Path::new(path), body.as_bytes()).unwrap(); + } + fs + } + + fn names(paths: &[PathBuf]) -> Vec { + paths.iter().map(|p| p.display().to_string()).collect() + } + + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_folder_expands_to_its_csv_files_in_name_order_and_skips_the_rest() { + let fs = folder(&[ + ("maat/coupling.csv", "entity\n"), + ("maat/notes.txt", "not data"), + ("maat/age.CSV", "entity\n"), + ("maat/out.improv", "v2025-04-09\n"), + ("maat/abs-churn.csv", "date\n"), + ("elsewhere/other.csv", "x\n"), + ]); + let paths = expand_import_paths(&fs, &[ImportPath::Folder("maat".into())]).unwrap(); + assert_eq!( + names(&paths), + ["maat/abs-churn.csv", "maat/age.CSV", "maat/coupling.csv"] + ); + } + + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_folder_with_no_csv_files_is_refused_by_name() { + let only_text = folder(&[("maat/notes.txt", "x"), ("maat/out.improv", "y")]); + let empty = MemoryFs::new(); + for fs in [&only_text, &empty] { + let err = expand_import_paths(fs, &[ImportPath::Folder("maat".into())]) + .expect_err("a folder with nothing to import is refused"); + assert_eq!(err.to_string(), "no CSV files in folder maat"); + } + } + + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn files_and_folders_expand_in_argument_order() { + let fs = folder(&[ + ("b/two.csv", "x\n"), + ("b/one.csv", "x\n"), + ("a.csv", "x\n"), + ("z.csv", "x\n"), + ]); + let args = [ + ImportPath::File("z.csv".into()), + ImportPath::Folder("b".into()), + ImportPath::File("a.csv".into()), + ]; + let paths = expand_import_paths(&fs, &args).unwrap(); + assert_eq!(names(&paths), ["z.csv", "b/one.csv", "b/two.csv", "a.csv"]); + } + + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn files_with_different_headers_merge_without_inventing_the_missing_columns() { + let fs = folder(&[ + ( + "m/coupling.csv", + "entity,coupled,degree\nsrc/a.rs,src/b.rs,80\n", + ), + ("m/age.csv", "entity,age-months\nsrc/a.rs,2\n"), + ]); + let merged = merge_csvs_from(&fs, &["m/coupling.csv", "m/age.csv"]).unwrap(); + assert!(merged.warnings.is_empty(), "{:?}", merged.warnings); + let source = |item: &str, columns: &[&str]| SourceFile { + item: item.to_string(), + columns: columns.iter().map(|c| c.to_string()).collect(), + }; + assert_eq!( + merged.sources, + [ + source("coupling", &["entity", "coupled", "degree"]), + source("age", &["entity", "age-months"]), + ] + ); + assert_eq!( + *merged.records, + [ + serde_json::json!({"entity": "src/a.rs", "coupled": "src/b.rs", "degree": "80", "File": "coupling"}), + serde_json::json!({"entity": "src/a.rs", "age-months": "2", "File": "age"}), + ] + ); + } + + /// improvise-8jk6.12 (3): two files with one stem are one item, and its + /// columns are both files'. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn two_files_with_one_stem_union_their_columns() { + let fs = folder(&[ + ("a/x.csv", "entity,one\ne,1\n"), + ("b/x.csv", "entity,two\ne,2\n"), + ]); + let merged = merge_csvs_from(&fs, &["a/x.csv", "b/x.csv"]).unwrap(); + let expected = SourceFile { + item: "x".to_string(), + columns: ["entity", "one", "two"].map(String::from).to_vec(), + }; + assert_eq!(merged.sources, [expected]); + } + // ── RFC 4180 edge cases ─────────────────────────────────────────── #[test] @@ -442,10 +680,7 @@ mod tests { assert!(warnings.is_empty()); assert_eq!(records.len(), 2); assert_eq!(records[0]["Region"], Value::String("East".to_string())); - assert_eq!( - records[0]["Revenue"], - Value::Number(serde_json::Number::from(1)) - ); + assert_eq!(records[0]["Revenue"], Value::String("1".to_string())); assert_eq!(records[1]["Region"], Value::String("West".to_string())); let (control, _) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEa\rst,1\n").unwrap(); @@ -500,7 +735,8 @@ mod tests { token.parse::().is_ok_and(|n| !n.is_finite()), "precondition: {token} parses as a non-finite f64" ); - assert_eq!(parse_csv_field(token), Value::String(token.to_string())); + let text = Value::String(token.to_string()); + assert_eq!(typed(&text), text); } } @@ -509,10 +745,10 @@ mod tests { #[test] fn an_ordinary_float_is_still_a_number() { assert_eq!( - parse_csv_field("1000.50"), + typed(&Value::String("1000.50".into())), Value::Number(serde_json::Number::from_f64(1000.50).unwrap()) ); - assert_eq!(parse_csv_field("0"), Value::Number(0.into())); + assert_eq!(typed(&Value::String("0".into())), Value::Number(0.into())); } /// improvise-5hfh: two equal header names are one JSON key, so the later @@ -521,9 +757,9 @@ mod tests { fn a_repeated_header_is_renamed_rather_than_overwriting_its_twin() { let (records, warnings) = parse_csv_bytes_with_warnings(b"A,B,A\n1,2,3\n").unwrap(); let map = records[0].as_object().unwrap(); - assert_eq!(map["A"], Value::Number(1.into())); - assert_eq!(map["B"], Value::Number(2.into())); - assert_eq!(map["A_2"], Value::Number(3.into())); + assert_eq!(map["A"], Value::String("1".into())); + assert_eq!(map["B"], Value::String("2".into())); + assert_eq!(map["A_2"], Value::String("3".into())); assert_eq!(warnings.len(), 1); assert!(warnings[0].contains("'A'") && warnings[0].contains("'A_2'")); assert!(warnings[0].contains("column 3"), "{}", warnings[0]); @@ -536,8 +772,8 @@ mod tests { let (records, _) = parse_csv_bytes_with_warnings(b"A,A_2,A\n1,2,3\n").unwrap(); let map = records[0].as_object().unwrap(); assert_eq!(map.len(), 3, "{map:?}"); - assert_eq!(map["A_2"], Value::Number(2.into())); - assert_eq!(map["A_3"], Value::Number(3.into())); + assert_eq!(map["A_2"], Value::String("2".into())); + assert_eq!(map["A_3"], Value::String("3".into())); } #[test] @@ -572,8 +808,9 @@ mod tests { assert_eq!(records.len(), 2); assert_eq!(records[0]["Date"], Value::String("03/31/2026".to_string())); + assert_eq!(records[0]["Amount"], Value::String("-50.00".to_string())); assert_eq!( - records[0]["Amount"], + typed(&records[0]["Amount"]), Value::Number(serde_json::Number::from_f64(-50.00).unwrap()) ); assert_eq!(records[0]["Flag"], Value::String("*".to_string())); @@ -583,7 +820,7 @@ mod tests { Value::String("VENMO PAYMENT 260331".to_string()) ); assert_eq!( - records[1]["Amount"], + typed(&records[1]["Amount"]), Value::Number(serde_json::Number::from_f64(-240.00).unwrap()) ); } diff --git a/crates/improvise-io/src/import/wizard.rs b/crates/improvise-io/src/import/wizard.rs index ccea6a31..5bac7ec5 100644 --- a/crates/improvise-io/src/import/wizard.rs +++ b/crates/improvise-io/src/import/wizard.rs @@ -1,21 +1,19 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use anyhow::{Result, anyhow}; use serde_json::Value; use super::analyzer::{ - DateComponent, FieldKind, FieldProposal, analyze_records, extract_array_at_path, + DateComponent, DateShape, FieldKind, FieldProposal, analyze_records, extract_array_at_path, extract_date_component, find_array_paths, }; -use crate::formula::parse_formula; +use super::csv_parser::{self, CsvRecords, FILE_CATEGORY, Merged, SourceFile}; +use crate::formula::{Formula, parse_formula}; +use crate::model::MAX_CATEGORIES; use crate::model::cell::{CellKey, CellValue}; -use crate::view::Axis; +use crate::view::{Axis, VIRTUAL_VIEW_NAMES, View, ViewStep, records_steps}; use crate::workbook::Workbook; -/// Mirrors the model's private `MAX_CATEGORIES` (regular categories only), to -/// pre-check proposals before the wizard's confirm step (improvise-mzv). -const MAX_CATEGORIES: usize = 12; - // ── Pipeline (no UI state) ──────────────────────────────────────────────────── /// Pure data + logic for turning a JSON value into a Model. @@ -32,6 +30,45 @@ pub struct ImportPipeline { pub formulas: Vec, /// What to do with records that share every coordinate (improvise-cgqt). pub on_duplicate: DuplicatePolicy, + /// The files a CSV merge read, each of which gets its own view. Empty for + /// a single file or a JSON import (improvise-lpzu). + pub sources: Vec, + /// What the records' string values are, fixed by the constructor (8jk6.4). + scalars: Scalars, +} + +/// How a record's values read. Why CSV is text: `context/code/formula-and-io.md`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scalars { + /// JSON: a string is text and a number is a number. + Typed, + /// CSV: every value is source text, read as a number only where one is wanted. + Text, +} + +impl Scalars { + /// The record as analysis sees it: typed, whatever its source. + fn typed_record(self, record: &Value) -> Value { + match (self, record) { + (Scalars::Text, Value::Object(map)) => Value::Object( + map.iter() + .map(|(k, v)| (k.clone(), csv_parser::typed(v))) + .collect(), + ), + (Scalars::Text, other) | (Scalars::Typed, other) => other.clone(), + } + } + + /// The value a measure takes from `value`, if it is a number. + fn number(self, value: &Value) -> Option { + match self { + Scalars::Typed => value.as_f64(), + Scalars::Text => value + .as_str() + .and_then(csv_parser::number_in) + .and_then(|n| n.as_f64()), + } + } } /// Several source records that land on one coordinate tuple (improvise-cgqt). @@ -56,7 +93,17 @@ pub enum DuplicatePolicy { } impl ImportPipeline { + /// A pipeline over a JSON value. pub fn new(raw: Value) -> Self { + Self::reading(raw, Scalars::Typed) + } + + /// A pipeline over CSV records, whose values are their source text. + pub fn from_csv(records: CsvRecords) -> Self { + Self::reading(Value::Array(records.into_values()), Scalars::Text) + } + + fn reading(raw: Value, scalars: Scalars) -> Self { let array_paths = find_array_paths(&raw); let mut pipeline = Self { raw: raw.clone(), @@ -67,6 +114,8 @@ impl ImportPipeline { model_name: "Imported Model".to_string(), formulas: vec![], on_duplicate: DuplicatePolicy::Refuse, + sources: vec![], + scalars, }; // Auto-select if root is an array or there is exactly one candidate path. @@ -80,11 +129,82 @@ impl ImportPipeline { pipeline } + /// A pipeline over a CSV merge, carrying its sources for the per-file views. + pub fn from_merge(merged: Merged) -> Self { + let mut pipeline = Self::from_csv(merged.records); + pipeline.seed_sources(merged.sources); + pipeline + } + + /// Every file keeps its `File` item and its columns, rows or none: a + /// header-only file is still a file (improvise-lpzu). + fn seed_sources(&mut self, sources: Vec) { + let items: Vec = sources.iter().map(|s| s.item.clone()).collect(); + match self.proposals.iter_mut().find(|p| p.field == FILE_CATEGORY) { + Some(file) => file.distinct_values = items, + None => self + .proposals + .push(unseen(FILE_CATEGORY, FieldKind::Category, items)), + } + for column in sources.iter().flat_map(|s| &s.columns) { + if !self.proposals.iter().any(|p| &p.field == column) { + self.proposals + .push(unseen(column, FieldKind::Label, vec![])); + } + } + self.sources = sources; + } + pub fn select_path(&mut self, path: &str) { self.selected_path = path.to_string(); if let Some(arr) = extract_array_at_path(&self.raw, path) { self.records = arr.clone(); - self.proposals = analyze_records(&self.records); + self.analyze(); + } + } + + /// Propose a kind for every field, reading the records as `scalars` says. + pub fn analyze(&mut self) { + let typed: Vec = self + .records + .iter() + .map(|r| self.scalars.typed_record(r)) + .collect(); + self.proposals = analyze_records(&typed); + } + + /// The warning for the records the build will skip, if it skips any. + pub fn skip_warning(&self) -> Option { + let skipped = self.records.iter().filter(|r| !r.is_object()).count(); + skip_warnings(self.records.len(), skipped) + } + + /// Why there is nothing to build, when no record is an object to place. + pub fn nothing_to_import(&self) -> Option { + let total = self.records.len(); + match (total, self.records.iter().any(Value::is_object)) { + (_, true) => None, + (0, false) => Some("Nothing to import: the source has no records".into()), + (_, false) => Some(format!( + "Nothing to import: all {total} records were skipped as not JSON objects" + )), + } + } + + /// The formulas, parsed. Every one that does not parse is named with its + /// error, never dropped (improvise-8jk6.5). + pub fn parsed_formulas(&self) -> Result> { + let mut parsed = vec![]; + let mut refused: Vec = vec![]; + for raw in &self.formulas { + match import_formula(raw) { + Ok(formula) => parsed.push(formula), + Err(e) => refused.push(e.to_string()), + } + } + match refused.is_empty() { + true => Ok(parsed), + false => Err(anyhow!(refused.join("\n"))), } } @@ -119,13 +239,23 @@ impl ImportPipeline { .filter(|p| p.accepted) .map(|p| match p.kind { FieldKind::Category => 1, - FieldKind::TimeCategory if p.date_format.is_some() => 1 + p.date_components.len(), + FieldKind::TimeCategory if p.date_format.is_some() => match p.date_shape { + DateShape::Hierarchy => 1, + DateShape::Split => 1 + p.date_components.len(), + }, FieldKind::TimeCategory => 1, FieldKind::Measure | FieldKind::Label => 0, }) .sum() } + /// Why the date step cannot advance: see [`Plan::spelling_clash`]. + pub fn spelling_clash(&self) -> Option { + let plan = self.plan(); + let placements: Vec = self.records.iter().map(|r| plan.place(r)).collect(); + plan.spelling_clash(&placements) + } + /// Every coordinate tuple more than one record lands on, in first-seen /// order. Known before the build, so the wizard can ask (improvise-cgqt). pub fn collisions(&self) -> Vec { @@ -180,6 +310,7 @@ impl ImportPipeline { && p.kind == FieldKind::TimeCategory && p.date_format.is_some() && !p.date_components.is_empty() + && p.date_shape == DateShape::Split }) .flat_map(|p| { let fmt = p.date_format.as_deref().unwrap(); @@ -194,11 +325,18 @@ impl ImportPipeline { }) }) .collect(); + let categories = accepted(&[FieldKind::Category, FieldKind::TimeCategory]); + let date_hierarchies = categories + .iter() + .filter_map(|p| DateHierarchy::of(p)) + .collect(); Plan { - categories: accepted(&[FieldKind::Category, FieldKind::TimeCategory]), + categories, measures: accepted(&[FieldKind::Measure]), labels: accepted(&[FieldKind::Label]), date_extractions, + date_hierarchies, + scalars: self.scalars, } } @@ -209,11 +347,18 @@ impl ImportPipeline { let categories = &plan.categories; let date_extractions = &plan.date_extractions; + if let Some(why) = self.nothing_to_import() { + return Err(anyhow!(why)); + } if categories.is_empty() { return Err(anyhow!("At least one category must be accepted")); } + let formulas = self.parsed_formulas()?; let placements: Vec = self.records.iter().map(|r| plan.place(r)).collect(); + if let Some(clash) = plan.spelling_clash(&placements) { + return Err(anyhow!(clash)); + } let collisions = collisions_among(&placements); let occurrence = match (&self.on_duplicate, collisions.is_empty()) { (_, true) | (DuplicatePolicy::Sum, false) => None, @@ -225,11 +370,16 @@ impl ImportPipeline { for cat_proposal in categories { wb.add_category(&cat_proposal.field)?; - for val in cat_proposal - .distinct_values + // A hierarchical date column's items are its components, added below. + let raw_items = match plan + .date_hierarchies .iter() - .filter(|v| !v.trim().is_empty()) + .any(|h| h.field == cat_proposal.field) { + true => &[][..], + false => &cat_proposal.distinct_values[..], + }; + for val in raw_items.iter().filter(|v| !v.trim().is_empty()) { wb.add_item(&cat_proposal.field, val)?; } } @@ -260,13 +410,9 @@ impl ImportPipeline { wb.add_item("_Measure", &m.field)?; } - // Per-category tally of records dropped because that category had no - // value; a record is charged to the first category it is missing. - let mut skipped_per_category = vec![0usize; categories.len()]; - let mut skipped_non_objects = 0usize; // Per-extraction tally of values the detected format could not parse. A // derived coordinate that is merely absent is improvise-yss5. - let mut unparsed_dates = vec![0usize; date_extractions.len()]; + let mut unparsed_dates = vec![0usize; date_extractions.len() + plan.date_hierarchies.len()]; // Two rows sharing every coordinate are one cell; last-row-wins lost the // earlier one (improvise-2cw9). The map only deduplicates — gotcha 18. let mut cells: Vec = vec![]; @@ -279,18 +425,16 @@ impl ImportPipeline { for (at, placement) in placements.into_iter().enumerate() { match placement { - Placement::NotObject => skipped_non_objects += 1, - Placement::Missing { cat_idx, partial } => { - for (cat, item) in &partial { - wb.add_item(cat, item)?; - } - skipped_per_category[cat_idx] += 1; - } + Placement::NotObject => {} Placement::Placed { mut coords, measures, unparsed, + chains, } => { + for chain in &chains { + add_date_chain(&mut wb, chain)?; + } for (cat, item) in &coords { wb.add_item(cat, item)?; } @@ -333,22 +477,30 @@ impl ImportPipeline { wb.model.set_cell(cell.key, CellValue::Number(cell.total)); } - // Parse and add formulas - // Formulas target the "_Measure" category by default. - let formula_cat: String = "_Measure".to_string(); - for raw in &self.formulas { - if let Ok(formula) = parse_formula(raw, &formula_cat) { - wb.model.add_formula(formula); - } + for formula in formulas { + wb.model.add_formula(formula); } - let mut warnings = skip_warnings( - self.records.len(), - categories, - &skipped_per_category, - skipped_non_objects, + for view in source_views(&wb, &self.sources, &plan) { + wb.views.insert(view.name.clone(), view); + } + + let mut warnings: Vec = self.skip_warning().into_iter().collect(); + let (split, whole) = unparsed_dates.split_at(date_extractions.len()); + warnings.extend(unparsed_date_warnings(date_extractions, split)); + warnings.extend( + plan.date_hierarchies + .iter() + .zip(whole) + .filter(|(_, n)| **n > 0) + .map(|(h, n)| { + format!( + "'{}' kept its raw value in {n} row(s), ungrouped; it did not match \ + the date format '{}'", + h.field, h.format + ) + }), ); - warnings.extend(unparsed_date_warnings(date_extractions, &unparsed_dates)); warnings.extend(collision_warning(&summed)); warnings.extend(occurrence.map(|name| separated_note(name, &collisions))); @@ -356,12 +508,129 @@ impl ImportPipeline { } } +/// A proposal for a column no record has a value in, which is what an +/// all-null column already gets from the analyzer (improvise-l1f5). +fn unseen(field: &str, kind: FieldKind, distinct_values: Vec) -> FieldProposal { + FieldProposal { + field: field.to_string(), + kind, + distinct_values, + accepted: true, + date_format: None, + date_components: vec![], + date_shape: DateShape::default(), + } +} + +/// One view per merged file, in file order, while `File` is a category. The +/// shape and why Filter: `context/code/formula-and-io.md` (improvise-lpzu). +fn source_views(wb: &Workbook, sources: &[SourceFile], plan: &Plan) -> Vec { + let file_is_a_category = plan.categories.iter().any(|c| c.field == FILE_CATEGORY); + let mut views: Vec = vec![]; + for source in sources.iter().filter(|_| file_is_a_category) { + let taken = |name: &str| { + wb.views.contains_key(name) + || VIRTUAL_VIEW_NAMES.contains(&name) + || views.iter().any(|v| v.name == name) + }; + let name = std::iter::once(source.item.clone()) + .chain((2usize..).map(|n| format!("{} {n}", source.item))) + .find(|name| !taken(name)) + .expect("a free name exists, there being finitely many views"); + views.push(source_view(wb, source, plan, name)); + } + views +} + +/// Filtered to the file; its own categories seated as a fresh import seats +/// them, every other one on None; other files' measures hidden. +fn source_view(wb: &Workbook, source: &SourceFile, plan: &Plan, name: String) -> View { + let has = |field: &str| source.columns.iter().any(|c| c == field); + let own = |cat: &str| { + has(cat) + || plan + .date_extractions + .iter() + .any(|(field, _, _, derived)| derived == cat && has(field)) + }; + let mut view = View::new(name); + for cat in wb.model.categories.values() { + match cat.kind.is_virtual() || own(&cat.name) { + true => view.on_category_added(&cat.name, cat.kind), + false => { + view.category_axes.insert(cat.name.clone(), Axis::None); + } + } + } + view.set_axis("_Measure", Axis::Page); + view.set_axis(FILE_CATEGORY, Axis::Filter); + view.set_page_selection(FILE_CATEGORY, &source.item); + let (mine, theirs): (Vec<&FieldProposal>, Vec<&FieldProposal>) = + plan.measures.iter().partition(|m| has(&m.field)); + for measure in theirs { + view.hide_item("_Measure", &measure.field); + } + match mine.first() { + Some(first) => view.set_page_selection("_Measure", &first.field), + None => into_records(&mut view, wb, &source.item), + } + view +} + +/// A file with no measure has no measure to page to: its view is its records, +/// still filtered to the file (improvise-8jk6.13). +fn into_records(view: &mut View, wb: &Workbook, item: &str) { + for step in records_steps(&wb.model) { + match step { + ViewStep::Axis { category, axis } => view.set_axis(&category, axis), + ViewStep::PageSelection { category, item } => view.set_page_selection(&category, &item), + } + } + view.set_axis(FILE_CATEGORY, Axis::Filter); + view.set_page_selection(FILE_CATEGORY, item); +} + /// The accepted fields by role, and the date parts derived from them. struct Plan<'a> { categories: Vec<&'a FieldProposal>, measures: Vec<&'a FieldProposal>, labels: Vec<&'a FieldProposal>, date_extractions: Vec<(&'a str, &'a str, DateComponent, String)>, + date_hierarchies: Vec>, + scalars: Scalars, +} + +/// A date column imported as ONE category: its items are the finest chosen +/// component, grouped by the coarser ones (improvise-8jk6.3, core.md §19). +struct DateHierarchy<'a> { + field: &'a str, + format: &'a str, + /// The chosen components, coarsest first. + levels: Vec, +} + +impl<'a> DateHierarchy<'a> { + fn of(p: &'a FieldProposal) -> Option { + let levels: Vec = DateComponent::COARSE_TO_FINE + .into_iter() + .filter(|c| p.date_components.contains(c)) + .collect(); + let hierarchical = + p.kind == FieldKind::TimeCategory && p.date_shape == DateShape::Hierarchy; + (hierarchical && !levels.is_empty()).then_some(Self { + field: &p.field, + format: p.date_format.as_deref()?, + levels, + }) + } + + /// Every level's name for one value, root first; the last is the item. + fn chain(&self, value: &str) -> Option> { + self.levels + .iter() + .map(|c| extract_date_component(value, self.format, *c)) + .collect() + } } /// Where one source record lands. Worked out once, for the build and for the @@ -373,11 +642,7 @@ enum Placement { coords: Vec<(String, String)>, measures: Vec<(String, f64)>, unparsed: Vec, - }, - /// No value for category `cat_idx`; `partial` is what preceded it. - Missing { - cat_idx: usize, - partial: Vec<(String, String)>, + chains: Vec, }, NotObject, } @@ -388,18 +653,34 @@ impl Plan<'_> { return Placement::NotObject; }; let mut coords: Vec<(String, String)> = vec![]; - // Charged only once this record survives the walk: a row dropped - // for a LATER missing category never lands (context/test-evidence). let mut unparsed: Vec = vec![]; + let mut chains: Vec = vec![]; - for (cat_idx, cat_proposal) in self.categories.iter().enumerate() { - let Some(v) = map.get(&cat_proposal.field).and_then(scalar_name) else { - return Placement::Missing { - cat_idx, - partial: coords, - }; + // No value is no coordinate: records are sparse (improvise-8jk6.2). + let named = |field: &str| map.get(field).and_then(scalar_name); + for (cat_proposal, v) in self + .categories + .iter() + .filter_map(|p| Some((p, named(&p.field)?))) + { + let hierarchy = self + .date_hierarchies + .iter() + .enumerate() + .find(|(_, h)| h.field == cat_proposal.field); + let item = match hierarchy.map(|(at, h)| (at, h.chain(&v))) { + Some((_, Some(chain))) => { + let item = chain.last().cloned().unwrap_or_default(); + chains.push((cat_proposal.field.clone(), chain)); + item + } + Some((at, None)) => { + unparsed.push(self.date_extractions.len() + at); + v.clone() + } + None => v.clone(), }; - coords.push((cat_proposal.field.clone(), v.clone())); + coords.push((cat_proposal.field.clone(), item)); // Extract date components from this field's value for (at, (field, fmt, comp, derived_name)) in self.date_extractions.iter().enumerate() { @@ -413,28 +694,101 @@ impl Plan<'_> { } } - // Attach label values as coords (missing labels become ""). - for lab in &self.labels { - let val = map - .get(&lab.field) - .and_then(scalar_name) - .unwrap_or_default(); - coords.push((lab.field.clone(), val)); + // Labels likewise: a blank label is no coordinate, never an item "". + for (lab, v) in self + .labels + .iter() + .filter_map(|lab| Some((lab, named(&lab.field)?))) + { + coords.push((lab.field.clone(), v)); } let measures = self .measures .iter() - .filter_map(|m| Some((m.field.clone(), map.get(&m.field)?.as_f64()?))) + .filter_map(|m| Some((m.field.clone(), self.scalars.number(map.get(&m.field)?)?))) .collect(); Placement::Placed { coords, measures, unparsed, + chains, } } } +impl Plan<'_> { + /// Why the build stops when an unparsed date is spelled like a generated + /// item or group (improvise-d0n6; formula-and-io.md, date hierarchy). + fn spelling_clash(&self, placements: &[Placement]) -> Option { + let generated = |field: &str| -> HashSet<&str> { + placements + .iter() + .filter_map(|p| match p { + Placement::Placed { chains, .. } => Some(chains), + Placement::NotObject => None, + }) + .flatten() + .filter(|(cat, _)| cat == field) + .flat_map(|(_, chain)| chain.iter().map(String::as_str)) + .collect() + }; + for (at, h) in self.date_hierarchies.iter().enumerate() { + let tally = self.date_extractions.len() + at; + let names = generated(h.field); + let mut clashing: Vec<&str> = vec![]; + for p in placements { + let Placement::Placed { + coords, unparsed, .. + } = p + else { + continue; + }; + let raw = coords.iter().find(|(c, _)| c == h.field).map(|(_, v)| v); + let clash = raw.filter(|v| { + unparsed.contains(&tally) + && names.contains(v.as_str()) + && !clashing.contains(&v.as_str()) + }); + clashing.extend(clash.map(String::as_str)); + } + if !clashing.is_empty() { + return Some(format!( + "'{}' has value(s) that do not match the date format '{}' but are spelled \ + like a month, quarter or year it generates: {}; fix them, or import the \ + column with the split shape", + h.field, + h.format, + clashing.join(", ") + )); + } + } + None + } +} + +/// One hierarchical date coordinate: its category and its chain, root first. +type DateChain = (String, Vec); + +/// Register a date item under its groups, and each group under its parent. +fn add_date_chain(wb: &mut Workbook, (cat, chain): &DateChain) -> Result<()> { + let Some((item, groups)) = chain.split_last() else { + return Ok(()); + }; + match groups.last() { + Some(group) => wb.add_item_in_group(cat, item, group)?, + None => wb.add_item(cat, item)?, + }; + let category = wb + .model + .category_mut(cat) + .ok_or_else(|| anyhow!("category '{cat}' vanished mid-import"))?; + for pair in groups.windows(2) { + category.add_group(crate::model::category::Group::new(&pair[1]).with_parent(&pair[0])); + } + Ok(()) +} + /// A tuple, and each record on it: its 1-based position and the measures it writes. type OnTuple<'a> = (&'a Vec<(String, String)>, Vec<(usize, Vec<&'a str>)>); @@ -541,37 +895,17 @@ fn unparsed_date_warnings( .collect() } -/// Human-readable warnings for records that could not be placed in the grid. -/// Empty when every record was imported. -fn skip_warnings( - total: usize, - categories: &[&FieldProposal], - skipped_per_category: &[usize], - skipped_non_objects: usize, -) -> Vec { - let mut warnings = vec![]; +/// An import formula, targeting `_Measure`, or the error naming it (improvise-8jk6.5). +fn import_formula(raw: &str) -> Result { + parse_formula(raw, "_Measure").map_err(|e| anyhow!("Formula error in '{raw}': {e}")) +} - let missing_total: usize = skipped_per_category.iter().sum(); - if missing_total > 0 { - let details: Vec = categories - .iter() - .zip(skipped_per_category) - .filter(|(_, n)| **n > 0) - .map(|(p, n)| format!("{} ({n})", p.field)) - .collect(); - warnings.push(format!( - "Skipped {missing_total} of {total} records missing category values: {}", - details.join(", ") - )); - } - - if skipped_non_objects > 0 { - warnings.push(format!( - "Skipped {skipped_non_objects} of {total} records that were not JSON objects" - )); - } - - warnings +/// The warning for records that could not be placed in the grid. A record +/// missing a category value is placed, so only a non-object is skipped. +fn skip_warnings(total: usize, skipped_non_objects: usize) -> Option { + (skipped_non_objects > 0).then(|| { + format!("Skipped {skipped_non_objects} of {total} records that were not JSON objects") + }) } // ── Wizard (UI state wrapped around the pipeline) ───────────────────────────── @@ -628,8 +962,12 @@ pub struct ImportWizard { impl ImportWizard { pub fn new(raw: Value) -> Self { - let pipeline = ImportPipeline::new(raw); + Self::from_pipeline(ImportPipeline::new(raw)) + } + /// A wizard over a pipeline the caller built, such as a CSV merge that + /// carries its sources (improvise-lpzu). + pub fn from_pipeline(pipeline: ImportPipeline) -> Self { let step = if pipeline.needs_path_selection() { WizardStep::SelectArrayPath } else if pipeline.records.is_empty() { @@ -749,9 +1087,13 @@ impl ImportWizard { fn validation_error(&self) -> Option { match self.step { WizardStep::ReviewProposals => self - .no_category_message() + .pipeline + .nothing_to_import() + .or_else(|| self.no_category_message()) .or_else(|| self.category_limit_message()), - WizardStep::ConfigureDates => self.category_limit_message(), + WizardStep::ConfigureDates => self + .category_limit_message() + .or_else(|| self.pipeline.spelling_clash()), WizardStep::ResolveDuplicates => match &self.pipeline.on_duplicate { DuplicatePolicy::Sum => None, DuplicatePolicy::Separate(_) => self.separate_blocked(), @@ -1029,6 +1371,18 @@ impl ImportWizard { } } + /// Switch the field at the cursor between ONE hierarchical category and the + /// flat split into one category per component (improvise-8jk6.3). + pub fn toggle_date_shape(&mut self) { + if let Some((pi, _)) = self.date_config_at_cursor() { + let proposal = &mut self.pipeline.proposals[pi]; + proposal.date_shape = match proposal.date_shape { + DateShape::Hierarchy => DateShape::Split, + DateShape::Split => DateShape::Hierarchy, + }; + } + } + // ── Formula editing ──────────────────────────────────────────────────────── /// Buffer for typing a new formula in the DefineFormulas step. @@ -1044,14 +1398,24 @@ impl ImportWizard { self.formula_buffer.pop(); } - /// Commit the current formula buffer to the pipeline's formula list. + /// Commit the current formula buffer to the pipeline's formula list. One + /// that does not parse stays in the buffer, with the error (improvise-8jk6.5). pub fn confirm_formula(&mut self) { let text = self.formula_buffer.trim().to_string(); - if !text.is_empty() { - self.pipeline.formulas.push(text); + match (text.is_empty(), import_formula(&text)) { + (false, Err(e)) => self.message = Some(e.to_string()), + (false, Ok(_)) => { + self.pipeline.formulas.push(text); + self.close_formula_edit(); + } + (true, _) => self.close_formula_edit(), } + } + + fn close_formula_edit(&mut self) { self.formula_buffer.clear(); self.formula_editing = false; + self.message = None; self.cursor = self.pipeline.formulas.len().saturating_sub(1); } @@ -1115,6 +1479,7 @@ impl ImportWizard { mod tests { use super::{ImportPipeline, collision_warning}; use crate::import::analyzer::FieldKind; + use crate::view::Axis; use serde_json::{Value, json}; #[test] @@ -1330,6 +1695,7 @@ mod tests { prop.date_format = Some("%m/%d/%Y".to_string()); prop.date_components.push(DateComponent::Month); prop.date_components.push(DateComponent::Year); + prop.date_shape = crate::import::analyzer::DateShape::Split; } } p @@ -1348,10 +1714,9 @@ mod tests { ); } + /// Evidence: `context/test-evidence/formula-and-io.md`. #[test] - fn a_row_dropped_for_a_missing_category_is_not_charged_to_the_date_tally() { - // `Date` is walked before `Region`, so a bad date is seen first and the - // record is only afterwards dropped for the missing `Region`. + fn a_row_kept_without_a_category_is_charged_to_the_date_tally() { let mut p = extracting_month_and_year(json!([ {"Date": "01/15/2025", "Region": "East", "Amount": 100.0}, {"Date": "2025-02-05", "Amount": 200.0}, @@ -1370,23 +1735,23 @@ mod tests { }) .map(|p| p.field.as_str()) .collect(); - assert_eq!( - categories, - vec!["Date", "Region"], - "the fixture only discriminates while Date is walked FIRST" - ); + assert_eq!(categories, vec!["Date", "Region"]); - let (_, warnings) = p.build_model_with_warnings().unwrap(); + let (wb, warnings) = p.build_model_with_warnings().unwrap(); assert!( - warnings.iter().all(|w| !w.contains("Date_Month")), - "the row never landed, so no derived column lost it: {warnings:?}" + stored(&wb).contains(&"Date=2025-02-05, _Measure=Amount = 200".to_string()), + "the row lands without Region or a derived part: {:?}", + stored(&wb) ); - assert_eq!( - warnings.len(), - 1, - "only the skipped-record warning: {warnings:?}" + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!( + warnings[0].contains("'Date_Month' missing from 1 row(s)"), + "{warnings:?}" + ); + assert!( + warnings[1].contains("'Date_Year' missing from 1 row(s)"), + "{warnings:?}" ); - assert!(warnings[0].contains("Region"), "{}", warnings[0]); } #[test] @@ -1434,6 +1799,7 @@ mod tests { for prop in &mut p.proposals { if prop.field == "Date" && prop.kind == FieldKind::TimeCategory { prop.date_components.push(DateComponent::Month); + prop.date_shape = crate::import::analyzer::DateShape::Split; } } let wb = p.build_model().unwrap(); @@ -1704,7 +2070,8 @@ mod tests { /// the final confirm. Evidence: `context/test-evidence/formula-and-io.md`. #[test] fn wizard_blocks_advance_when_over_category_limit() { - let mut w = ImportWizard::new(category_records(13)); + let over = crate::model::MAX_CATEGORIES + 1; + let mut w = ImportWizard::new(category_records(over)); assert_eq!(w.step, WizardStep::ReviewProposals); assert_eq!( w.pipeline @@ -1712,8 +2079,8 @@ mod tests { .iter() .filter(|p| p.accepted && p.kind == FieldKind::Category) .count(), - 13, - "sanity: all 13 fields proposed as accepted categories" + over, + "sanity: every field proposed as an accepted category" ); w.advance(); @@ -1730,11 +2097,15 @@ mod tests { msg.contains("Measure"), "message should tell the user how to fix it: {msg}" ); + assert!( + msg.contains(&format!("max {}", crate::model::MAX_CATEGORIES)), + "message should name the model's limit: {msg}" + ); } #[test] fn wizard_allows_advance_at_exactly_category_limit() { - let mut w = ImportWizard::new(category_records(12)); + let mut w = ImportWizard::new(category_records(crate::model::MAX_CATEGORIES)); assert_eq!(w.step, WizardStep::ReviewProposals); w.advance(); assert_ne!(w.step, WizardStep::ReviewProposals); @@ -1742,7 +2113,7 @@ mod tests { #[test] fn wizard_advance_unblocks_after_marking_field_as_measure() { - let mut w = ImportWizard::new(category_records(13)); + let mut w = ImportWizard::new(category_records(crate::model::MAX_CATEGORIES + 1)); w.advance(); assert_eq!(w.step, WizardStep::ReviewProposals); @@ -2003,14 +2374,15 @@ mod tests { "null must not become an item: {items:?}" ); assert_eq!( - warnings.len(), - 1, - "the dropped record is reported: {warnings:?}" + stored(&wb), + [ + "_Measure=revenue, region=East = 100", + "_Measure=revenue = 200" + ] ); assert!( - warnings[0].contains("region"), - "warning names the field: {}", - warnings[0] + warnings.is_empty(), + "the record is kept, not skipped: {warnings:?}" ); } @@ -2058,9 +2430,15 @@ mod tests { .collect(); assert!(!items.contains(&""), "no empty-named item: {items:?}"); assert_eq!( - warnings.len(), - 1, - "the dropped record is reported: {warnings:?}" + stored(&wb), + [ + "_Measure=revenue, region=East = 100", + "_Measure=revenue = 200" + ] + ); + assert!( + warnings.is_empty(), + "the record is kept, not skipped: {warnings:?}" ); } @@ -2088,57 +2466,442 @@ mod tests { assert!(warnings.is_empty(), "nothing dropped: {warnings:?}"); } + /// improvise-8jk6.2: a record without the field keeps its cell. It was + /// skipped until records became sparse. #[test] - fn build_model_record_with_missing_category_value_skipped() { - // If a record is missing a category field, it should be skipped + fn build_model_record_with_missing_category_field_is_kept() { let raw = json!([ {"region": "East", "revenue": 100.0}, {"revenue": 200.0}, // missing "region" ]); - let p = ImportPipeline::new(raw); - let wb = p.build_model().unwrap(); - // Only one cell should exist (the East record) - use crate::model::cell::CellKey; - let k = CellKey::new(vec![ - ("_Measure".to_string(), "revenue".to_string()), - ("region".to_string(), "East".to_string()), - ]); - assert!(wb.model.get_cell(&k).is_some()); + let wb = ImportPipeline::new(raw).build_model().unwrap(); + assert_eq!( + stored(&wb), + [ + "_Measure=revenue, region=East = 100", + "_Measure=revenue = 200" + ] + ); } - /// improvise-5j9: skipping a record for a missing category value was - /// silent. The warning must name the count, the total and the field(s). + /// improvise-8jk6.2: a blank category value used to drop the whole record. + /// Evidence: `context/test-evidence/formula-and-io.md`. #[test] - fn build_model_warns_about_records_skipped_for_missing_category() { + fn a_record_with_no_value_for_a_category_is_kept_without_that_coordinate() { let raw = json!([ - {"region": "East", "revenue": 100.0}, - {"revenue": 200.0}, // missing "region" - {"revenue": 300.0}, // missing "region" + {"Payee": "Acme", "Settlement Date": "2026-01-05", "Amount": 10.0}, + {"Payee": "Bolt", "Settlement Date": null, "Amount": 20.0}, + {"Payee": "Acme", "Settlement Date": "", "Amount": 40.0}, ]); - let p = ImportPipeline::new(raw); - let (_, warnings) = p.build_model_with_warnings().unwrap(); + let mut p = ImportPipeline::new(raw); + force_category(&mut p, "Settlement Date"); + let (wb, warnings) = p.build_model_with_warnings().unwrap(); + assert_eq!( - warnings.len(), - 1, - "one skipped-record warning: {warnings:?}" + stored(&wb), + [ + "Payee=Acme, Settlement Date=2026-01-05, _Measure=Amount = 10", + "Payee=Bolt, _Measure=Amount = 20", + "Payee=Acme, _Measure=Amount = 40", + ] ); + let items = &wb.model.category("Settlement Date").unwrap().items; + assert_eq!(items.keys().collect::>(), ["2026-01-05"]); + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// CSV files as a folder import merges them, sources and all. + fn merged_pipeline(files: &[(&str, &str)]) -> ImportPipeline { + use crate::import::csv_parser::merge_csvs_from; + use crate::storage::{MemoryFs, Storage}; + let fs = MemoryFs::new(); + for (path, body) in files { + fs.write(std::path::Path::new(path), body.as_bytes()) + .unwrap(); + } + let paths: Vec<&str> = files.iter().map(|(p, _)| *p).collect(); + let merged = merge_csvs_from(&fs, &paths).unwrap(); + assert!(merged.warnings.is_empty(), "{:?}", merged.warnings); + ImportPipeline::from_merge(merged) + } + + /// code-maat's age and coupling reports. + const CODE_MAAT: [(&str, &str); 2] = [ + ( + "maat/age.csv", + "entity,age-months\nsrc/a.rs,2\nsrc/c.rs,7\n", + ), + ( + "maat/coupling.csv", + "entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\nsrc/b.rs,src/c.rs,40,3\n", + ), + ]; + + /// The code-maat merge without its sources, so no per-file views. + fn code_maat_without_views() -> ImportPipeline { + let mut p = merged_pipeline(&CODE_MAAT); + p.sources.clear(); + p + } + + /// Every non-empty cell a view's grid draws, row by row. + fn drawn(wb: &crate::workbook::Workbook, view: &str) -> Vec { + use crate::view::GridLayout; + let layout = GridLayout::with_frozen_records(&wb.model, &wb.views[view], None); + (0..layout.row_count()) + .flat_map(|r| (0..layout.col_count()).map(move |c| (r, c))) + .filter_map(|(r, c)| layout.evaluate(&wb.model, &layout.cell_key(r, c)?)) + .filter_map(|v| v.as_f64()) + .collect() + } + + /// improvise-8jk6.2: where a record with no `Date` shows up, axis by axis. + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_record_with_no_coordinate_is_drawn_only_where_that_category_is_off_the_grid() { + use crate::model::cell::CellKey; + use crate::persistence::{format_md, parse_md}; + use crate::view::{GridLayout, drill_snapshot}; + let mut p = ImportPipeline::new(json!([ + {"Payee": "Acme", "Date": "d1", "Amount": 10.0}, + {"Payee": "Bolt", "Amount": 20.0}, + ])); + force_category(&mut p, "Date"); + let (mut wb, _) = p.build_model_with_warnings().unwrap(); + let mut seat = |date: Axis, payee: Axis| { + let view = wb.active_view_mut(); + view.set_axis("_Measure", Axis::None); + view.set_axis("Payee", payee); + view.set_axis("Date", date); + (drawn(&wb, "Default"), wb.clone()) + }; + assert_eq!( + seat(Axis::Column, Axis::Row).0, + [10.0], + "no column holds Bolt" + ); + assert_eq!(seat(Axis::Page, Axis::Row).0, [10.0], "no page holds Bolt"); + assert_eq!( + seat(Axis::None, Axis::Row).0, + [10.0, 20.0], + "None counts it" + ); + + let (_, mut records) = seat(Axis::None, Axis::None); + records.active_view_mut().set_axis("_Index", Axis::Row); + records.active_view_mut().set_axis("_Dim", Axis::Column); + let layout = GridLayout::with_frozen_records(&records.model, records.active_view(), None); + let row: Vec = (0..layout.col_count()) + .map(|c| layout.records_display(1, c).unwrap_or_default()) + .collect(); + assert_eq!(row, ["", "Bolt", "", "20", ""], "a blank Date cell"); + + let text = format_md(&records); assert!( - warnings[0].contains('2'), - "warning counts skipped rows: {}", - warnings[0] + text.contains("\nPayee=Bolt, _Measure=Amount = 20\n"), + "{text}" ); - assert!( - warnings[0].contains('3'), - "warning names the total: {}", - warnings[0] + assert_eq!(format_md(&parse_md(&text).unwrap()), text); + + let drill = |cat: &str, item: &str| { + let key = CellKey::new(vec![(cat.to_string(), item.to_string())]); + drill_snapshot(&records.model, &key).len() + }; + assert_eq!((drill("Payee", "Bolt"), drill("Date", "d1")), (1, 1)); + } + + /// improvise-lpzu, the Codex P2 on PR #314: a header-only file keeps its + /// identity. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_header_only_file_keeps_its_file_item_its_columns_and_its_view() { + use crate::view::GridLayout; + let (wb, _) = merged_pipeline(&[ + ("maat/age.csv", "entity,age-months\nsrc/a.rs,2\n"), + ("maat/coupling.csv", "entity,coupled,degree,average-revs\n"), + ("maat/rev.csv", "entity,n-revs\nsrc/a.rs,9\n"), + ]) + .build_model_with_warnings() + .unwrap(); + let files: Vec<&String> = wb.model.category("File").unwrap().items.keys().collect(); + assert_eq!(files, ["age", "coupling", "rev"]); + for column in ["coupled", "degree", "average-revs"] { + assert!(wb.model.category(column).is_some(), "{column} is missing"); + } + let view = &wb.views["coupling"]; + assert_eq!(view.page_selection("File"), Some("coupling")); + let layout = GridLayout::with_frozen_records(&wb.model, view, None); + assert!(layout.is_records_mode() && layout.row_count() == 0); + assert_eq!(drawn(&wb, "age"), [2.0]); + } + + /// improvise-8jk6.13: a view never pages onto a measure it hides. + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_file_with_no_measure_gets_a_records_view_not_another_files_measure() { + use crate::view::GridLayout; + let (wb, _) = merged_pipeline(&[ + ("t/a.csv", "entity,x\ne1,1\n"), + ("t/tags.csv", "entity,tag\ne1,red\n"), + ]) + .build_model_with_warnings() + .unwrap(); + let tags = &wb.views["tags"]; + assert_eq!(tags.page_selection("_Measure"), None); + assert_ne!(tags.axis_of("_Measure"), Axis::Page, "{tags:?}"); + let layout = GridLayout::with_frozen_records(&wb.model, tags, None); + assert!(layout.is_records_mode(), "{tags:?}"); + assert_eq!( + layout.row_count(), + 0, + "a record with no measure has no cell" ); - assert!( - warnings[0].contains("region"), - "warning names the missing field: {}", - warnings[0] + assert!(!wb.views["a"].is_records_layout()); + } + + /// improvise-8jk6.12 (1): a date part derived from the file's own column + /// is the file's. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_files_view_seats_the_date_parts_of_its_own_date_column() { + use crate::import::analyzer::DateComponent; + let mut p = merged_pipeline(&[ + ("m/abs-churn.csv", "date,added\n2026-01-02,10\n"), + ("m/age.csv", "entity,age-months\nsrc/a.rs,2\n"), + ]); + let date = p.proposals.iter_mut().find(|f| f.field == "date").unwrap(); + assert_eq!(date.kind, FieldKind::TimeCategory); + date.date_components = vec![DateComponent::Month]; + date.date_shape = crate::import::analyzer::DateShape::Split; + let (wb, _) = p.build_model_with_warnings().unwrap(); + assert_ne!(wb.views["abs-churn"].axis_of("date_Month"), Axis::None); + assert_eq!(wb.views["age"].axis_of("date_Month"), Axis::None); + } + + /// improvise-8jk6.3 under improvise-8jk6.12: the default shape puts the date + /// parts IN the file's own column, so that column is what its view seats. + #[test] + fn a_files_view_seats_its_own_hierarchical_date_column() { + use crate::import::analyzer::DateComponent; + let mut p = merged_pipeline(&[ + ( + "m/abs-churn.csv", + "date,added +2026-01-02,10 +", + ), + ( + "m/age.csv", + "entity,age-months +src/a.rs,2 +", + ), + ]); + let date = p.proposals.iter_mut().find(|f| f.field == "date").unwrap(); + date.date_components = vec![DateComponent::Year, DateComponent::Month]; + let (wb, _) = p.build_model_with_warnings().unwrap(); + assert!(wb.model.category("date_Month").is_none(), "one category"); + assert_ne!(wb.views["abs-churn"].axis_of("date"), Axis::None); + assert_eq!(wb.views["age"].axis_of("date"), Axis::None); + } + + /// improvise-8jk6.12 (2): two files of one batch never share a view name. + /// Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn two_files_whose_names_collide_in_one_batch_keep_a_view_each() { + let (wb, _) = merged_pipeline(&[ + ("d/Default.csv", "Region,Revenue\nEast,1\n"), + ("d/Default 2.csv", "Region,Revenue\nEast,2\n"), + ]) + .build_model_with_warnings() + .unwrap(); + let filters: Vec<(&str, Option<&str>)> = wb + .views + .values() + .map(|v| (v.name.as_str(), v.page_selection("File"))) + .collect(); + assert_eq!( + filters, + [ + ("Default", None), + ("Default 2", Some("Default")), + ("Default 2 2", Some("Default 2")), + ] ); } + /// improvise-8jk6.12 (4): a whitespace-only value is no coordinate, never + /// an item. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_whitespace_only_category_value_is_no_coordinate() { + let (wb, _) = ImportPipeline::new(json!([ + {"region": "East", "revenue": 1.0}, + {"region": " ", "revenue": 2.0}, + ])) + .build_model_with_warnings() + .unwrap(); + let items: Vec<&String> = wb.model.category("region").unwrap().items.keys().collect(); + assert_eq!(items, ["East"]); + assert_eq!( + stored(&wb), + ["_Measure=revenue, region=East = 1", "_Measure=revenue = 2"] + ); + } + + /// improvise-lpzu: each merged file gets a view of its own records and + /// columns. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_merge_gives_each_file_a_view_projected_to_its_columns() { + let (wb, _) = merged_pipeline(&CODE_MAAT) + .build_model_with_warnings() + .unwrap(); + assert_eq!( + wb.views.keys().collect::>(), + ["Default", "age", "coupling"] + ); + assert_eq!(wb.active_view, "Default"); + + let age = &wb.views["age"]; + assert_eq!(age.axis_of("File"), Axis::Filter); + assert_eq!(age.page_selection("File"), Some("age")); + assert_eq!(age.axis_of("entity"), Axis::Row); + assert_eq!(age.axis_of("coupled"), Axis::None); + assert_eq!(age.axis_of("_Measure"), Axis::Page); + assert_eq!(age.page_selection("_Measure"), Some("age-months")); + assert!(age.is_hidden("_Measure", "degree") && age.is_hidden("_Measure", "average-revs")); + assert!(!age.is_hidden("_Measure", "age-months")); + assert_eq!(drawn(&wb, "age"), [2.0, 7.0]); + + let coupling = &wb.views["coupling"]; + assert_eq!(coupling.page_selection("File"), Some("coupling")); + assert_eq!(coupling.axis_of("entity"), Axis::Row); + assert_eq!(coupling.axis_of("coupled"), Axis::Column); + assert_eq!(coupling.page_selection("_Measure"), Some("average-revs")); + assert!(coupling.is_hidden("_Measure", "age-months")); + assert!(!coupling.is_hidden("_Measure", "average-revs")); + assert_eq!(drawn(&wb, "coupling"), [5.0, 3.0]); + } + + /// improvise-lpzu: two files of one schema are told apart by the filter, + /// not by their columns. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_files_view_holds_only_that_files_records() { + let (wb, _) = merged_pipeline(&[ + ("s/q1.csv", "Region,Revenue\nEast,10\nWest,1\n"), + ("s/q2.csv", "Region,Revenue\nEast,20\nWest,2\n"), + ]) + .build_model_with_warnings() + .unwrap(); + assert_eq!(drawn(&wb, "q1"), [10.0, 1.0]); + assert_eq!(drawn(&wb, "q2"), [20.0, 2.0]); + } + + /// improvise-lpzu: a view name is taken from the file only while it is + /// free. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_files_view_name_skips_names_already_held() { + let (wb, _) = merged_pipeline(&[ + ("d/Default.csv", "Region,Revenue\nEast,1\n"), + ("d/_Drill.csv", "Region,Revenue\nEast,2\n"), + ]) + .build_model_with_warnings() + .unwrap(); + assert_eq!( + wb.views.keys().collect::>(), + ["Default", "Default 2", "_Drill 2"] + ); + assert_eq!( + wb.views["Default 2"].page_selection("File"), + Some("Default") + ); + assert_eq!(wb.views["_Drill 2"].page_selection("File"), Some("_Drill")); + } + + /// improvise-lpzu: the per-file views persist, and in the same order on + /// every import. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn per_file_views_survive_a_round_trip_and_repeat_byte_for_byte() { + use crate::persistence::{format_md, parse_md}; + let build = || { + let (wb, _) = merged_pipeline(&CODE_MAAT) + .build_model_with_warnings() + .unwrap(); + format_md(&wb) + }; + let text = build(); + assert_eq!(build(), text); + let reread = parse_md(&text).unwrap(); + assert_eq!(format_md(&reread), text); + assert_eq!( + reread.views.keys().collect::>(), + ["Default", "age", "coupling"] + ); + assert_eq!(reread.views["age"].axis_of("File"), Axis::Filter); + assert_eq!(reread.views["age"].page_selection("File"), Some("age")); + assert_eq!(drawn(&reread, "coupling"), [5.0, 3.0]); + } + + /// improvise-lpzu: a column a file lacks is absent from its records, never + /// 0, "" or a skip. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn files_with_different_headers_import_as_one_sparse_model() { + let p = code_maat_without_views(); + let kinds: Vec<(&str, FieldKind)> = p + .proposals + .iter() + .map(|f| (f.field.as_str(), f.kind.clone())) + .collect(); + for (field, kind) in [ + ("entity", FieldKind::Category), + ("coupled", FieldKind::Category), + ("File", FieldKind::Category), + ("age-months", FieldKind::Measure), + ("degree", FieldKind::Measure), + ("average-revs", FieldKind::Measure), + ] { + assert!(kinds.contains(&(field, kind.clone())), "{field}: {kinds:?}"); + } + + let (wb, warnings) = p.build_model_with_warnings().unwrap(); + let mut cells = stored(&wb); + cells.sort(); + assert_eq!( + cells, + [ + "File=age, _Measure=age-months, entity=src/a.rs = 2", + "File=age, _Measure=age-months, entity=src/c.rs = 7", + "File=coupling, _Measure=average-revs, coupled=src/b.rs, entity=src/a.rs = 5", + "File=coupling, _Measure=average-revs, coupled=src/c.rs, entity=src/b.rs = 3", + "File=coupling, _Measure=degree, coupled=src/b.rs, entity=src/a.rs = 80", + "File=coupling, _Measure=degree, coupled=src/c.rs, entity=src/b.rs = 40", + ] + ); + for cat in wb.model.regular_category_names() { + let items = &wb.model.category(cat).unwrap().items; + assert!(!items.contains_key(""), "{cat} has an empty item"); + } + assert!(warnings.is_empty(), "{warnings:?}"); + } + + /// improvise-lpzu: the label half of the rule — absent, not an item named + /// "". Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_label_column_a_record_lacks_is_no_coordinate_not_an_empty_item() { + let mut p = code_maat_without_views(); + p.proposals + .iter_mut() + .find(|f| f.field == "coupled") + .unwrap() + .kind = FieldKind::Label; + let (wb, _) = p.build_model_with_warnings().unwrap(); + let mut cells = stored(&wb); + cells.sort(); + assert_eq!( + cells[0], + "File=age, _Measure=age-months, entity=src/a.rs = 2" + ); + let items = &wb.model.category("coupled").unwrap().items; + assert!(!items.contains_key(""), "{:?}", items.keys()); + } + #[test] fn build_model_warns_nothing_when_every_record_is_complete() { let raw = json!([ @@ -2195,6 +2958,7 @@ mod tests { for prop in &mut p.proposals { if prop.field == "Date" { prop.date_components.push(DateComponent::Month); + prop.date_shape = crate::import::analyzer::DateShape::Split; } } let wb = p.build_model().unwrap(); @@ -2579,10 +3343,10 @@ mod tests { ); } - /// Twelve categories and a collision: the case the user hit. Option (b) - /// needs a thirteenth, so it is shown, disabled, with the remedy. + /// A full set of categories and a collision: the case the user hit. Option + /// (b) needs one more, so it is shown, disabled, with the remedy. fn wizard_at_the_cap() -> ImportWizard { - let mut recs = category_records(12); + let mut recs = category_records(crate::model::MAX_CATEGORIES); for r in recs.as_array_mut().unwrap() { r["amount"] = json!(1.0); } @@ -2601,7 +3365,8 @@ mod tests { assert_eq!(options.len(), 3); assert_eq!(options[0].disabled, None); let reason = options[1].disabled.clone().expect("separate is disabled"); - assert!(reason.contains("12"), "{reason}"); + let cap = crate::model::MAX_CATEGORIES.to_string(); + assert!(reason.contains(&cap), "names the cap: {reason}"); assert!(reason.contains("Skip"), "names the remedy: {reason}"); assert_eq!(options[2].disabled, None); @@ -2706,6 +3471,7 @@ mod tests { w.advance(); assert_eq!(w.step, WizardStep::ConfigureDates, "setup"); w.toggle_date_component(); + w.toggle_date_shape(); w.advance(); assert_eq!(w.step, WizardStep::ResolveDuplicates, "setup"); w.move_cursor(1); @@ -2724,4 +3490,330 @@ mod tests { w.message ); } + + /// improvise-8jk6.5. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_formula_that_does_not_parse_stops_the_build_and_is_named() { + let mut p = ImportPipeline::new(json!([{"region": "East", "revenue": 1.0}])); + p.formulas = vec![ + "Good = revenue".into(), + "Bad = revenue +".into(), + "Worse = * 2".into(), + ]; + let err = p.build_model().expect_err("an unparseable formula refuses"); + let msg = err.to_string(); + assert!(msg.contains("Formula error in 'Bad = revenue +'"), "{msg}"); + assert!(msg.contains("Formula error in 'Worse = * 2'"), "{msg}"); + assert!(!msg.contains("Good"), "{msg}"); + } + + /// improvise-8jk6.5: the wizard refuses where the text can still be + /// fixed. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn confirming_a_formula_that_does_not_parse_keeps_it_out_and_the_text_in() { + let mut w = sample_wizard(); + w.start_formula_edit(); + for c in "Bad = revenue +".chars() { + w.push_formula_char(c); + } + w.confirm_formula(); + assert!(w.pipeline.formulas.is_empty(), "{:?}", w.pipeline.formulas); + assert_eq!(w.formula_buffer, "Bad = revenue +"); + assert!(w.formula_editing); + let message = w.message.clone().unwrap_or_default(); + assert!( + message.starts_with("Formula error in 'Bad = revenue +'"), + "{message}" + ); + } + + /// improvise-8jk6.4: the role reads the text. Evidence: + /// `context/test-evidence/formula-and-io.md`. + #[test] + fn a_csv_column_read_as_a_category_keeps_its_text_and_as_a_measure_its_number() { + use crate::import::csv_parser::parse_csv_bytes_with_warnings; + use crate::model::cell::{CellKey, CellValue}; + let csv = b"commit,lines\n00565796,3\n220944e2,5\n"; + let (records, _) = parse_csv_bytes_with_warnings(csv).unwrap(); + let mut p = ImportPipeline::from_csv(records); + let kind = |p: &ImportPipeline, f: &str| { + p.proposals + .iter() + .find(|x| x.field == f) + .unwrap() + .kind + .clone() + }; + assert_eq!(kind(&p, "commit"), FieldKind::Measure, "analysis types"); + let commit = p.proposals.iter_mut().find(|x| x.field == "commit"); + commit.unwrap().kind = FieldKind::Category; + let wb = p.build_model().unwrap(); + let items: Vec<&String> = wb.model.category("commit").unwrap().items.keys().collect(); + assert_eq!(items, ["00565796", "220944e2"]); + let key = CellKey::new(vec![ + ("_Measure".into(), "lines".into()), + ("commit".into(), "220944e2".into()), + ]); + assert_eq!(wb.model.get_cell(&key), Some(&CellValue::Number(5.0))); + } + + /// improvise-8jk6.6 meets lpzu's header-only rule: a folder of nothing but + /// headers kept no record. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_folder_of_only_header_only_files_keeps_no_record_and_refuses() { + let p = merged_pipeline(&[("m/a.csv", "entity,x\n"), ("m/b.csv", "entity,y\n")]); + assert!(p.proposals.iter().any(|f| f.field == "File"), "setup"); + let msg = p.build_model().expect_err("refuses").to_string(); + assert_eq!(msg, "Nothing to import: the source has no records"); + } + + /// improvise-8jk6.6: the wizard asks on the review step, where another + /// path can still be chosen. Evidence: the test-evidence doc. + #[test] + fn the_review_step_says_when_no_record_can_be_imported() { + let mut w = ImportWizard::new(json!([1, 2])); + assert_eq!(w.step, WizardStep::ReviewProposals, "setup"); + w.advance(); + assert_eq!(w.step, WizardStep::ReviewProposals); + assert_eq!( + w.message.as_deref(), + Some("Nothing to import: all 2 records were skipped as not JSON objects") + ); + } + + /// improvise-8jk6.6. Evidence: `context/test-evidence/formula-and-io.md`. + #[test] + fn a_build_that_keeps_no_record_refuses_and_says_why() { + let skipped = ImportPipeline::new(json!([1, "two", null])); + let msg = skipped.build_model().expect_err("refuses").to_string(); + assert_eq!( + msg, + "Nothing to import: all 3 records were skipped as not JSON objects" + ); + let empty = ImportPipeline::new(json!([])); + let msg = empty.build_model().expect_err("refuses").to_string(); + assert_eq!(msg, "Nothing to import: the source has no records"); + } +} + +/// improvise-8jk6.3: a date column imports as ONE hierarchical category. +#[cfg(test)] +mod date_hierarchy { + use super::{DuplicatePolicy, ImportPipeline}; + use crate::import::analyzer::{DateComponent, DateShape, FieldKind}; + use crate::model::cell::CellKey; + use crate::view::GridLayout; + use serde_json::json; + + /// Two days of one month, a month in the next quarter, and January of the + /// NEXT year, so a month must stay unique across years. + fn records() -> serde_json::Value { + json!([ + {"Date": "01/15/2024", "Amount": 1.0}, + {"Date": "01/20/2024", "Amount": 10.0}, + {"Date": "04/02/2024", "Amount": 100.0}, + {"Date": "01/03/2025", "Amount": 1000.0}, + ]) + } + + fn pipeline(components: &[DateComponent], shape: DateShape) -> ImportPipeline { + let mut p = ImportPipeline::new(records()); + p.on_duplicate = DuplicatePolicy::Sum; + let date = p.proposals.iter_mut().find(|p| p.field == "Date").unwrap(); + assert_eq!(date.kind, FieldKind::TimeCategory, "precondition"); + date.date_components = components.to_vec(); + date.date_shape = shape; + p + } + + fn cell(month: &str) -> CellKey { + CellKey::new(vec![ + ("Date".into(), month.into()), + ("_Measure".into(), "Amount".into()), + ]) + } + + /// The default shape: ONE category named after the column, its items the + /// months, grouped into quarters grouped into years. + #[test] + fn a_date_column_imports_as_one_category_of_months_in_quarters_in_years() { + use DateComponent::*; + let p = pipeline(&[Month, Year, Quarter], DateShape::default()); + assert_eq!(p.proposed_category_count(), 1, "one slot, not four"); + let wb = p.build_model().unwrap(); + let names: Vec<&str> = wb.model.regular_category_names(); + assert_eq!( + names, + vec!["Date"], + "no Date_Year, Date_Quarter or Date_Month" + ); + let date = wb.model.category("Date").unwrap(); + let items: Vec<&str> = date.items.keys().map(String::as_str).collect(); + assert_eq!(items, vec!["2024-01", "2024-04", "2025-01"]); + assert_eq!(date.item_chain("2024-01"), vec!["2024", "2024-Q1"]); + assert_eq!(date.item_chain("2025-01"), vec!["2025", "2025-Q1"]); + assert_eq!( + wb.model.get_cell(&cell("2024-01")).and_then(|v| v.as_f64()), + Some(11.0), + "two days of one month are one cell" + ); + assert_eq!( + wb.model.get_cell(&cell("2025-01")).and_then(|v| v.as_f64()), + Some(1000.0), + "January 2025 is not January 2024" + ); + } + + /// Chosen levels only: without Quarter, months group straight into years. + #[test] + fn a_level_left_unchosen_is_skipped_not_emptied() { + use DateComponent::*; + let wb = pipeline(&[Year, Month], DateShape::Hierarchy) + .build_model() + .unwrap(); + let date = wb.model.category("Date").unwrap(); + assert_eq!(date.item_chain("2024-04"), vec!["2024"]); + let wb = pipeline(&[Quarter], DateShape::Hierarchy) + .build_model() + .unwrap(); + let date = wb.model.category("Date").unwrap(); + let items: Vec<&str> = date.items.keys().map(String::as_str).collect(); + assert_eq!( + items, + vec!["2024-Q1", "2024-Q2", "2025-Q1"], + "flat quarters" + ); + assert!(date.item_chain("2024-Q1").is_empty()); + } + + /// The user's ruling on improvise-felo.6: the hierarchy opens folded, one + /// row per year. + #[test] + fn an_imported_date_hierarchy_opens_folded_to_its_years() { + use DateComponent::*; + let wb = pipeline(&[Year, Quarter, Month], DateShape::Hierarchy) + .build_model() + .unwrap(); + let layout = GridLayout::with_frozen_records(&wb.model, wb.active_view(), None); + let rows: Vec = (0..layout.row_count()) + .map(|r| layout.row_label(r)) + .collect(); + assert_eq!(rows, vec!["2024", "2025"]); + let v = layout.evaluate(&wb.model, &layout.cell_key(0, 0).unwrap()); + assert_eq!( + v.and_then(|v| v.as_f64()), + Some(111.0), + "2024 folds its months" + ); + } + + /// The flat split stays available as a choice, and is what it was. + #[test] + fn the_split_shape_keeps_the_raw_dates_and_one_category_per_component() { + use DateComponent::*; + let p = pipeline(&[Year, Month], DateShape::Split); + assert_eq!(p.proposed_category_count(), 3); + let wb = p.build_model().unwrap(); + let mut names: Vec<&str> = wb.model.regular_category_names(); + names.sort_unstable(); + assert_eq!(names, vec!["Date", "Date_Month", "Date_Year"]); + assert!( + wb.model + .category("Date") + .unwrap() + .items + .contains_key("01/15/2024") + ); + } + + /// A value the detected format cannot read keeps its raw text as an + /// ungrouped item, and the build says so rather than dropping the record. + #[test] + fn an_unparsed_date_keeps_its_value_and_is_warned_about() { + use DateComponent::*; + let mut p = pipeline(&[Year, Month], DateShape::Hierarchy); + let date = p.proposals.iter_mut().find(|p| p.field == "Date").unwrap(); + date.date_format = Some("%Y-%m-%d".into()); + let (wb, warnings) = p.build_model_with_warnings().unwrap(); + assert!(wb.model.category("Date_Year").is_none(), "one category"); + let date = wb.model.category("Date").unwrap(); + assert!( + date.items.contains_key("01/15/2024"), + "the raw value is kept" + ); + assert!( + date.item_chain("01/15/2024").is_empty(), + "and it is ungrouped" + ); + assert!( + warnings + .iter() + .any(|w| w.contains("'Date' kept its raw value in 4 row(s)")), + "{warnings:?}" + ); + } + + /// A %Y-%m-%d column imported Year > Month, one valid date and one raw + /// value spelled `raw`. + fn with_raw(raw: &str) -> ImportPipeline { + use DateComponent::*; + let mut p = ImportPipeline::new(json!([ + {"Date": "2024-01-15", "Amount": 1.0}, + {"Date": raw, "Amount": 2.0}, + ])); + p.on_duplicate = DuplicatePolicy::Sum; + let date = p.proposals.iter_mut().find(|p| p.field == "Date").unwrap(); + date.kind = FieldKind::TimeCategory; + date.date_format = Some("%Y-%m-%d".into()); + date.date_components = vec![Year, Month]; + date.date_shape = DateShape::Hierarchy; + p + } + + /// A raw value spelled like a generated month would merge with it. + #[test] + fn a_raw_date_spelled_like_a_generated_item_is_refused() { + let err = with_raw("2024-01").build_model_with_warnings().unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("'Date'") && msg.contains("2024-01"), "{msg}"); + assert!(msg.contains("split"), "{msg}"); + } + + /// A raw value spelled like a generated year would sit beside its group. + #[test] + fn a_raw_date_spelled_like_a_generated_group_is_refused() { + let err = with_raw("2024").build_model_with_warnings().unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("'Date'") && msg.contains("2024"), "{msg}"); + } + + /// The control: a raw value spelled like nothing generated still imports, + /// so the refusal above is not a blanket one. + #[test] + fn a_raw_date_spelled_like_nothing_generated_is_kept_ungrouped() { + let (wb, warnings) = with_raw("sometime").build_model_with_warnings().unwrap(); + let date = wb.model.category("Date").unwrap(); + assert!(date.item_chain("sometime").is_empty()); + assert!(date.items.contains_key("2024-01"), "{warnings:?}"); + assert!( + warnings.iter().any(|w| w.contains("1 row(s)")), + "{warnings:?}" + ); + } + + /// The clash is answered on the date step, where `s` fixes it, not on the + /// last step three screens later (improvise-907). + #[test] + fn the_date_step_will_not_advance_over_a_spelling_clash() { + use super::{ImportWizard, WizardStep}; + let mut w = ImportWizard::from_pipeline(with_raw("2024")); + w.step = WizardStep::ConfigureDates; + w.advance(); + assert_eq!(w.step, WizardStep::ConfigureDates); + let msg = w.message.clone().unwrap_or_default(); + assert!(msg.contains("'Date'") && msg.contains("2024"), "{msg}"); + w.toggle_date_shape(); + w.advance(); + assert_ne!(w.step, WizardStep::ConfigureDates, "split clears it"); + } } diff --git a/crates/improvise-io/src/persistence/export.rs b/crates/improvise-io/src/persistence/export.rs index 30a50c13..44f4a68d 100644 --- a/crates/improvise-io/src/persistence/export.rs +++ b/crates/improvise-io/src/persistence/export.rs @@ -22,9 +22,9 @@ pub fn export_csv_to( storage.write(path, &render_csv(workbook, view_name)?) } -/// Render a view as CSV bytes. Separated from writing so the bytes can go to -/// any [`Storage`] backend, not just a file (improvise-fuj). -fn render_csv(workbook: &Workbook, view_name: &str) -> Result> { +/// Render a view as CSV bytes for any [`Storage`] backend or a download frame +/// (improvise-fuj, improvise-2mqz). +pub fn render_csv(workbook: &Workbook, view_name: &str) -> Result> { let view = workbook .views .get(view_name) @@ -214,6 +214,7 @@ mod tests { wb.add_category("Type").unwrap(); wb.add_item_in_group("Month", "Jan", "Q1").unwrap(); wb.add_item_in_group("Month", "Feb", "Q1").unwrap(); + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("Type", "Food").unwrap(); for (month, value) in [("Jan", 3.0), ("Feb", 4.0)] { wb.model.set_cell( @@ -226,7 +227,7 @@ mod tests { v.set_axis("Month", Axis::Row); v.set_axis("Type", Axis::Column); v.prune_empty = true; - v.toggle_group_collapse("Month", "Q1"); + v.toggle_group_collapse("Month", &["Q1"]); } let csv = String::from_utf8(super::render_csv(&wb, "Default").unwrap()).unwrap(); let rows: Vec<&str> = csv.lines().skip(1).collect(); diff --git a/crates/improvise-io/src/persistence/format.rs b/crates/improvise-io/src/persistence/format.rs index 11854147..39046be4 100644 --- a/crates/improvise-io/src/persistence/format.rs +++ b/crates/improvise-io/src/persistence/format.rs @@ -2,7 +2,7 @@ use crate::model::category::CategoryKind; use crate::model::cell::{CellKey, CellValue}; -use crate::view::Axis; +use crate::view::{Axis, GroupFold}; use crate::workbook::Workbook; // The one home of the pipe-quoting rule (improvise-i2m5); a name written here @@ -119,19 +119,37 @@ pub fn format_md(workbook: &Workbook) -> String { if view.prune_empty { w!(out, "prune: true"); } - for (prefix, map) in [ - ("hidden", &view.hidden_items), - ("collapsed", &view.collapsed_groups), - ] { - let mut pairs: Vec<_> = map - .iter() - .flat_map(|(cat, items)| { - items.iter().map(move |item| (cat.as_str(), item.as_str())) - }) - .collect(); - pairs.sort(); - for (cat, item) in pairs { - w!(out, "{prefix}: {}/{}", quote_name(cat), quote_name(item)); + let mut hidden: Vec<_> = view + .hidden_items + .iter() + .flat_map(|(cat, items)| items.iter().map(move |item| (cat.as_str(), item.as_str()))) + .collect(); + hidden.sort(); + for (cat, item) in hidden { + w!(out, "hidden: {}/{}", quote_name(cat), quote_name(item)); + } + let mut depths: Vec<_> = view.expand_depth.iter().collect(); + depths.sort(); + // Never bare: `expanded: row` would read back as an axis line. + for (cat, depth) in depths { + w!(out, "expanded({depth}): {}", quote_name(cat)); + } + let mut folds: Vec<_> = view + .group_folds + .iter() + .flat_map(|(cat, groups)| { + groups + .iter() + .map(move |(g, fold)| (cat.as_str(), g.as_str(), *fold)) + }) + .collect(); + folds.sort(); + for (cat, group, fold) in folds { + let target = format!("{}/{}", quote_name(cat), quote_name(group)); + match fold { + GroupFold::Collapsed => w!(out, "collapsed: {target}"), + GroupFold::Expanded(n) if n.get() == 1 => w!(out, "expanded: {target}"), + GroupFold::Expanded(n) => w!(out, "expanded({n}): {target}"), } } } @@ -370,7 +388,8 @@ mod tests { #[test] fn format_md_collapsed_group() { let mut m = two_cat_model(); - m.active_view_mut().toggle_group_collapse("Type", "MyGroup"); + m.active_view_mut() + .set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed); let text = format_md(&m); assert!(text.contains("collapsed: Type/MyGroup")); } diff --git a/crates/improvise-io/src/persistence/improv.pest b/crates/improvise-io/src/persistence/improv.pest index b4b60135..fe32d253 100644 --- a/crates/improvise-io/src/persistence/improv.pest +++ b/crates/improvise-io/src/persistence/improv.pest @@ -120,7 +120,9 @@ view_section = { view_entry* } -view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line } +// `axis_line` is tried before `expanded_line`, so a category named `expanded` +// keeps its `expanded: row`; the writer never spells a category depth bare. +view_entry = _{ format_line | prune_line | hidden_line | collapsed_line | axis_line | expanded_line } axis_line = { name ~ ": " ~ axis_kind ~ (", " ~ name)? ~ NEWLINE ~ blank_lines @@ -135,6 +137,12 @@ prune_line = { "prune: " ~ bool_kw ~ NEWLINE ~ blank_lines } bool_kw = @{ "true" | "false" } hidden_line = { "hidden: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines } collapsed_line = { "collapsed: " ~ name ~ "/" ~ name ~ NEWLINE ~ blank_lines } +// Groups start collapsed. `expanded(N): Cat` opens the category's top N levels; +// `expanded(N): Cat/Group` opens the group and N-1 levels beneath it. A bare +// `expanded:` is N = 1, and N is at least 1. context/code/formula-and-io.md. +expanded_line = { "expanded" ~ fold_depth? ~ ": " ~ name ~ ("/" ~ name)? ~ NEWLINE ~ blank_lines } +fold_depth = { "(" ~ depth_number ~ ")" } +depth_number = @{ '1'..'9' ~ ASCII_DIGIT* } // ── Names ──────────────────────────────────────────────────────────────────── // diff --git a/crates/improvise-io/src/persistence/mod.rs b/crates/improvise-io/src/persistence/mod.rs index ee8d579a..4baf0d11 100644 --- a/crates/improvise-io/src/persistence/mod.rs +++ b/crates/improvise-io/src/persistence/mod.rs @@ -36,7 +36,7 @@ mod test_helpers { } } -pub use export::{export_csv, export_csv_to}; +pub use export::{export_csv, export_csv_to, render_csv}; pub use format::format_md; pub use parse::parse_md; diff --git a/crates/improvise-io/src/persistence/parse.rs b/crates/improvise-io/src/persistence/parse.rs index a28a76d0..e2661cf7 100644 --- a/crates/improvise-io/src/persistence/parse.rs +++ b/crates/improvise-io/src/persistence/parse.rs @@ -9,10 +9,10 @@ use pest_derive::Parser; use crate::formula::{AggFunc, parse_formula}; use crate::model::category::{CategoryKind, Group}; use crate::model::cell::{CellKey, CellValue}; -use crate::view::Axis; +use crate::view::{Axis, GroupFold}; use crate::workbook::Workbook; -use crate::formula::quote::unescape_pipe_body; +use crate::formula::quote::{quote_name, unescape_pipe_body}; #[derive(Parser)] #[grammar = "persistence/improv.pest"] @@ -40,9 +40,74 @@ struct PView { format: String, prune_empty: bool, hidden: Vec<(String, String)>, - collapsed: Vec<(String, String)>, + folds: Vec, } +/// One `collapsed:` or `expanded(N):` line. `group` is `None` for the category +/// form, which only `expanded` has. +struct PFold { + category: String, + group: Option, + fold: GroupFold, +} + +/// A file the grammar accepts and a model cannot hold (improvise-felo.6). +/// Typed, so the generator's property can tell it from a parse failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// `> A[B]` and `> B[A]`: a hierarchy must be a forest. + HierarchyCycle { + category: String, + groups: Vec, + }, + /// One view gives one group (or one category's depth) two different folds. + FoldConflict { + view: String, + category: String, + group: Option, + }, +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::HierarchyCycle { category, groups } => write!( + f, + "category {}: the group parents form a cycle through {}", + quote_name(category), + groups + .iter() + .map(|g| quote_name(g)) + .collect::>() + .join(" > ") + ), + Refusal::FoldConflict { + view, + category, + group: Some(group), + } => write!( + f, + "view {}: group {}/{} is both expanded and collapsed, or expanded to two depths", + quote_name(view), + quote_name(category), + quote_name(group) + ), + Refusal::FoldConflict { + view, + category, + group: None, + } => write!( + f, + "view {}: category {} is expanded to two depths", + quote_name(view), + quote_name(category) + ), + } + } +} + +impl std::error::Error for Refusal {} + /// Everything pass 1 collects, before any of it is resolved against the model. #[derive(Default)] struct ParsedFile { @@ -332,7 +397,31 @@ fn walk_axis_line(entry: Pair<'_, Rule>, pv: &mut PView) -> Result<()> { Ok(()) } -/// `## View: Name` plus its axis, format, hidden and collapsed lines. +/// `expanded(N): Cat` or `expanded(N): Cat/Group`; a bare `expanded:` is N = 1. +fn walk_expanded_line(entry: Pair<'_, Rule>) -> Result { + let mut depth = std::num::NonZeroUsize::MIN; + let mut names = Vec::new(); + for part in entry.into_inner() { + match part.as_rule() { + Rule::fold_depth => { + let digits = first_str(part)?; + depth = digits + .parse() + .with_context(|| format!("expanded({digits}): depth out of range"))?; + } + _ => names.push(extract_name(part)?), + } + } + let mut names = names.into_iter(); + let category = names.next().context("expanded_line: no category")?; + Ok(PFold { + category, + group: names.next(), + fold: GroupFold::Expanded(depth), + }) +} + +/// `## View: Name` plus its axis, format, hidden, collapsed and expanded lines. fn walk_view_section(pair: Pair<'_, Rule>) -> Result { let mut inner = pair.into_inner(); let mut pv = PView { @@ -342,7 +431,7 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result { format: String::new(), prune_empty: false, hidden: Vec::new(), - collapsed: Vec::new(), + folds: Vec::new(), }; for entry in inner { match entry.as_rule() { @@ -350,7 +439,15 @@ fn walk_view_section(pair: Pair<'_, Rule>) -> Result { Rule::format_line => pv.format = first_str(entry)?, Rule::prune_line => pv.prune_empty = first_str(entry)? == "true", Rule::hidden_line => pv.hidden.push(extract_name_pair(entry)?), - Rule::collapsed_line => pv.collapsed.push(extract_name_pair(entry)?), + Rule::collapsed_line => { + let (category, group) = extract_name_pair(entry)?; + pv.folds.push(PFold { + category, + group: Some(group), + fold: GroupFold::Collapsed, + }); + } + Rule::expanded_line => pv.folds.push(walk_expanded_line(entry)?), other => bail!("unhandled rule in view_section: {other:?}"), } } @@ -411,6 +508,13 @@ fn build_category(wb: &mut Workbook, pc: &PCategory) -> Result<()> { None => cat.add_group(Group::new(group_name).with_parent(parent)), } } + if let Some(cycle) = cat.hierarchy_cycle() { + return Err(Refusal::HierarchyCycle { + category: pc.name.clone(), + groups: cycle.into_iter().map(String::from).collect(), + } + .into()); + } Ok(()) } @@ -436,8 +540,29 @@ fn build_view(wb: &mut Workbook, pv: &PView) -> Result<()> { for (cat, item) in &pv.hidden { view.hide_item(cat, item); } - for (cat, grp) in &pv.collapsed { - view.toggle_group_collapse(cat, grp); + for PFold { + category, + group, + fold, + } in &pv.folds + { + let conflict = || Refusal::FoldConflict { + view: pv.name.clone(), + category: category.clone(), + group: group.clone(), + }; + match (group, fold) { + (Some(g), _) => match view.group_fold(category, g) { + Some(held) if held != *fold => return Err(conflict().into()), + _ => view.set_group_fold(category, g, *fold), + }, + (None, GroupFold::Expanded(n)) => match view.expand_depth(category) { + 0 => view.set_expand_depth(category, n.get()), + held if held != n.get() => return Err(conflict().into()), + _ => {} + }, + (None, GroupFold::Collapsed) => bail!("a category-wide collapse has no spelling"), + } } Ok(()) } @@ -534,6 +659,7 @@ mod grammar_coverage { Rule::axis_line | Rule::axis_kind => "walk_axis_line", Rule::format_line | Rule::prune_line | Rule::bool_kw => "walk_view_section", Rule::hidden_line | Rule::collapsed_line => "walk_view_section", + Rule::expanded_line | Rule::fold_depth | Rule::depth_number => "walk_expanded_line", Rule::view_entry => "silent: hoisted into view_section", Rule::name => "silent: hoisted into its parent", Rule::pipe_quoted | Rule::pipe_inner | Rule::bare_name => "extract_name", @@ -1059,12 +1185,14 @@ mod parser_edge_cases { fn collapsed_group_with_slash_in_name() { let mut m = Workbook::new("Test"); m.add_category("Type").unwrap(); - m.active_view_mut().toggle_group_collapse("Type", "Q1/Q2"); + m.active_view_mut() + .set_group_fold("Type", "Q1/Q2", crate::view::GroupFold::Collapsed); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); assert!( - loaded.active_view().is_group_collapsed("Type", "Q1/Q2"), + loaded.active_view().group_fold("Type", "Q1/Q2") + == Some(crate::view::GroupFold::Collapsed), "Collapsed group with '/' in name was corrupted.\n{text}" ); } @@ -1604,6 +1732,24 @@ mod generator { }) } + /// improvise-felo.6: the round-trip properties see `expanded(N):` lines only + /// if the walk emits them, depths included. + #[test] + fn the_generator_writes_expanded_lines_with_and_without_a_depth() { + use proptest::strategy::ValueTree; + use proptest::test_runner::TestRunner; + + let mut runner = TestRunner::deterministic(); + let strategy = improv_file(); + let files: Vec = (0..512) + .map(|_| strategy.new_tree(&mut runner).unwrap().current()) + .collect(); + let lines = || files.iter().flat_map(|f| f.lines()); + assert!(lines().any(|l| l.starts_with("expanded(")), "a depth"); + assert!(lines().any(|l| l.starts_with("expanded: ")), "a bare line"); + assert!(lines().any(|l| l.starts_with("collapsed: ")), "a collapse"); + } + /// improvise-8shu. Evidence: `context/test-evidence/formula-and-io.md`. #[test] fn the_grammar_is_parsed_once_however_many_files_the_strategy_yields() { @@ -1625,7 +1771,7 @@ mod generator { #[cfg(test)] mod grammar_prop_tests { - use super::{generator, parse_md}; + use super::{Refusal, generator, parse_md}; use crate::persistence::format_md; use proptest::prelude::*; @@ -1633,11 +1779,13 @@ mod grammar_prop_tests { proptest! { #![proptest_config(ProptestConfig::with_cases(256))] - /// parse(generate()) — every generated file parses without error. + /// parse(generate()) — every generated file parses, or is refused for a + /// named reason the grammar cannot express (a `Refusal`, improvise-felo.6). #[test] fn generated_file_parses(file in generator::improv_file()) { let result = parse_md(&file); - prop_assert!(result.is_ok(), + let refused = result.as_ref().err().is_some_and(|e| e.downcast_ref::().is_some()); + prop_assert!(result.is_ok() || refused, "Generated file failed to parse:\n{}\nError: {}", file, result.unwrap_err()); } diff --git a/crates/improvise-io/src/persistence/roundtrip_tests.rs b/crates/improvise-io/src/persistence/roundtrip_tests.rs index 2e341495..a6c84ceb 100644 --- a/crates/improvise-io/src/persistence/roundtrip_tests.rs +++ b/crates/improvise-io/src/persistence/roundtrip_tests.rs @@ -267,10 +267,198 @@ fn parse_md_round_trips_text_cell_values() { #[test] fn parse_md_round_trips_collapsed_group() { let mut m = two_cat_model(); - m.active_view_mut().toggle_group_collapse("Type", "MyGroup"); + m.active_view_mut() + .set_group_fold("Type", "MyGroup", crate::view::GroupFold::Collapsed); let text = format_md(&m); let loaded = parse_md(&text).unwrap(); - assert!(loaded.active_view().is_group_collapsed("Type", "MyGroup")); + assert!( + loaded.active_view().group_fold("Type", "MyGroup") + == Some(crate::view::GroupFold::Collapsed) + ); +} + +// ── Group hierarchies and folds (improvise-felo.6) ───────────────────── + +/// Year > Quarter > Month as a file spells it: `> Quarter[Year]` lines. +const DATES: &str = "v2025-04-09\n# T\nInitial View: Default\n\n## View: Default\nDate: row\n\ + _Measure: column\n@FOLDS@\n## Category: Date\n\ + - |2024-01|[Q1-2024]\n- |2024-04|[Q2-2024]\n- |2025-01|[Q1-2025]\n\ + > Q1-2024[|2024|]\n> Q2-2024[|2024|]\n> Q1-2025[|2025|]\n\n\ + ## Category: _Measure\n- Revenue\n"; + +fn dates_with(folds: &str) -> String { + DATES.replace("@FOLDS@", folds) +} + +/// Every spelling of a fold, together in one view, survives save and load — +/// and the writer's own text is stable. +#[test] +fn expanded_and_collapsed_lines_round_trip_together() { + use crate::view::GroupFold; + let text = dates_with( + "expanded(2): Date\ncollapsed: Date/Q1-2024\nexpanded: Date/Q2-2024\n\ + expanded(3): Date/|2025|", + ); + let loaded = parse_md(&text).unwrap(); + let v = loaded.active_view(); + assert_eq!(v.expand_depth("Date"), 2); + assert_eq!(v.group_fold("Date", "Q1-2024"), Some(GroupFold::Collapsed)); + assert_eq!( + v.group_fold("Date", "Q2-2024"), + Some(GroupFold::Expanded(std::num::NonZeroUsize::MIN)) + ); + assert_eq!( + v.group_fold("Date", "2025"), + Some(GroupFold::Expanded(std::num::NonZeroUsize::new(3).unwrap())) + ); + let written = format_md(&loaded); + for line in [ + "expanded(2): Date", + "collapsed: Date/Q1-2024", + "expanded: Date/Q2-2024", + "expanded(3): Date/|2025|", + ] { + assert!(written.lines().any(|l| l == line), "{line} in:\n{written}"); + } + assert_eq!(format_md(&parse_md(&written).unwrap()), written); +} + +/// A file from before improvise-felo.6 carries only `collapsed:` lines. Every +/// group opens closed, the ones it names AND the ones it does not. +#[test] +fn an_old_file_with_only_collapsed_lines_loads_closed_and_keeps_its_lines() { + let text = dates_with("collapsed: Date/Q1-2024"); + let loaded = parse_md(&text).unwrap(); + let v = loaded.active_view(); + let cat = loaded.model.category("Date").unwrap(); + for group in ["2024", "Q1-2024", "Q2-2024", "2025"] { + assert!( + v.is_group_collapsed("Date", &cat.group_chain(group)), + "{group} opens closed" + ); + } + assert!( + format_md(&loaded) + .lines() + .any(|l| l == "collapsed: Date/Q1-2024"), + "the writer keeps the line the file had, though it agrees with the default" + ); +} + +/// The user's ruling: expanded and collapsed on ONE group is an error, named. +#[test] +fn a_group_both_expanded_and_collapsed_is_refused_naming_view_and_group() { + use super::parse::Refusal; + for folds in [ + "collapsed: Date/Q1-2024\nexpanded: Date/Q1-2024", + "expanded(2): Date/Q1-2024\ncollapsed: Date/Q1-2024", + "expanded: Date/Q1-2024\nexpanded(2): Date/Q1-2024", + ] { + let err = parse_md(&dates_with(folds)).unwrap_err(); + assert_eq!( + err.downcast_ref::(), + Some(&Refusal::FoldConflict { + view: "Default".into(), + category: "Date".into(), + group: Some("Q1-2024".into()), + }), + "{folds}: {err}" + ); + assert!(err.to_string().contains("Default"), "{err}"); + assert!(err.to_string().contains("Date/Q1-2024"), "{err}"); + } + let err = parse_md(&dates_with("expanded(2): Date\nexpanded(3): Date")).unwrap_err(); + assert!(err.downcast_ref::().is_some(), "{err}"); +} + +/// Not the error: a DIFFERENT group collapsed inside an expanded region, and +/// the same line twice. +#[test] +fn a_collapsed_group_inside_an_expanded_region_loads() { + let text = dates_with( + "expanded(2): Date\nexpanded(2): Date/|2024|\ncollapsed: Date/Q1-2024\n\ + collapsed: Date/Q1-2024", + ); + let loaded = parse_md(&text).unwrap(); + let v = loaded.active_view(); + assert!(v.is_group_collapsed("Date", &["2024", "Q1-2024"])); + assert!(!v.is_group_collapsed("Date", &["2024", "Q2-2024"])); +} + +/// A cycle among `>` lines is refused on load, naming its members. +#[test] +fn a_cycle_among_group_parents_is_refused_naming_it() { + use super::parse::Refusal; + let text = DATES + .replace("@FOLDS@", "") + .replace("> Q1-2025[|2025|]", "> Q1-2025[|2025|]\n> |2025|[Q1-2025]"); + let err = parse_md(&text).unwrap_err(); + match err.downcast_ref::() { + Some(Refusal::HierarchyCycle { category, groups }) => { + assert_eq!(category, "Date"); + let mut groups = groups.clone(); + groups.sort(); + assert_eq!(groups, vec!["2025", "Q1-2025"]); + } + other => panic!("not a cycle refusal: {other:?} ({err})"), + } +} + +/// `expanded(0)` opens nothing and is not a spelling; `(x)` is not a depth. +#[test] +fn a_malformed_depth_is_a_parse_error() { + assert!( + parse_md(&dates_with("expanded(10): Date")).is_ok(), + "the control" + ); + for bad in ["expanded(0): Date", "expanded(x): Date", "expanded(): Date"] { + assert!(parse_md(&dates_with(bad)).is_err(), "{bad}"); + } +} + +/// A category named `expanded` keeps its axis line, and a category whose NAME is +/// an axis keyword keeps its depth: the writer never spells a depth bare. +#[test] +fn an_axis_line_for_a_category_named_expanded_is_still_an_axis_line() { + let mut wb = Workbook::new("T"); + wb.add_category("expanded").unwrap(); + wb.add_category("row").unwrap(); + wb.active_view_mut().set_axis("expanded", Axis::Column); + wb.active_view_mut().set_expand_depth("row", 1); + let loaded = parse_md(&format_md(&wb)).unwrap(); + assert_eq!(loaded.active_view().axis_of("expanded"), Axis::Column); + assert_eq!(loaded.active_view().expand_depth("row"), 1); +} + +/// `z` writes through ONE map, so no sequence of toggles can save a file that +/// names a group both ways. Every toggle order over three levels, twice over. +#[test] +fn no_sequence_of_toggles_writes_a_conflicting_file() { + let base = parse_md(&dates_with("expanded(1): Date")).unwrap(); + let chains: [&[&str]; 4] = [ + &["2024"], + &["2024", "Q1-2024"], + &["2024", "Q2-2024"], + &["2025", "Q1-2025"], + ]; + for order in 0..(4usize.pow(4)) { + let mut wb = base.clone(); + let mut code = order; + for _ in 0..4 { + let chain = chains[code % 4]; + code /= 4; + wb.active_view_mut().toggle_group_collapse("Date", chain); + let text = format_md(&wb); + let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + for chain in chains { + assert_eq!( + back.active_view().is_group_collapsed("Date", chain), + wb.active_view().is_group_collapsed("Date", chain), + "{chain:?} after order {order}:\n{text}" + ); + } + } + } } // ── parse_md: number format roundtrip ─────────────────────────────── @@ -336,7 +524,8 @@ fn full_roundtrip_preserves_all_features() { m.active_view_mut().set_axis("Month", Axis::Page); m.active_view_mut().set_page_selection("Month", "Jan"); m.active_view_mut().hide_item("Type", "Gas"); - m.active_view_mut().toggle_group_collapse("Type", "G1"); + m.active_view_mut() + .set_group_fold("Type", "G1", crate::view::GroupFold::Collapsed); m.active_view_mut().number_format = ",.0".to_string(); let text = format_md(&m); @@ -361,7 +550,7 @@ fn full_roundtrip_preserves_all_features() { assert_eq!(v.axis_of("Month"), Axis::Page); assert_eq!(v.page_selection("Month"), Some("Jan")); assert!(v.is_hidden("Type", "Gas")); - assert!(v.is_group_collapsed("Type", "G1")); + assert!(v.group_fold("Type", "G1") == Some(crate::view::GroupFold::Collapsed)); assert_eq!(v.number_format, ",.0"); } @@ -842,7 +1031,7 @@ fn a_csv_header_with_an_embedded_newline_survives_a_save() { let csv = "\"Region\nName\",Amount\nEast,1\nWest,2\n"; let (records, _) = crate::import::csv_parser::parse_csv_bytes_with_warnings(csv.as_bytes()).unwrap(); - let wb = crate::import::wizard::ImportPipeline::new(serde_json::Value::Array(records)) + let wb = crate::import::wizard::ImportPipeline::from_csv(records) .build_model() .unwrap(); assert!( @@ -1187,7 +1376,7 @@ fn a_label_category_survives_the_round_trip_as_a_label() { #[test] fn a_document_at_the_category_limit_plus_a_label_reloads() { let mut wb = Workbook::new("T"); - for i in 0..12 { + for i in 0..crate::model::MAX_CATEGORIES { wb.add_category(format!("C{i}")).unwrap(); } wb.add_label_category("Note").unwrap(); @@ -1197,7 +1386,10 @@ fn a_document_at_the_category_limit_plus_a_label_reloads() { loaded.model.category("Note").unwrap().kind, CategoryKind::Label ); - assert_eq!(loaded.model.regular_category_names().len(), 12); + assert_eq!( + loaded.model.regular_category_names().len(), + crate::model::MAX_CATEGORIES + ); } /// A file written before the `kind:` line existed reads its categories as @@ -1234,3 +1426,28 @@ fn a_loaded_label_category_is_not_on_a_pivot_axis() { ); } } + +/// ChatGPT Codex on #317: a fold built through the public API must survive its +/// own save. `Expanded(0)` is now unconstructible; every depth that is survives. +#[test] +fn every_fold_the_api_can_build_reads_back_from_the_file_it_writes() { + use crate::view::GroupFold; + use std::num::NonZeroUsize; + let base = parse_md(&dates_with("")).unwrap(); + for n in 1..=3 { + let depth = NonZeroUsize::new(n).unwrap(); + for fold in [GroupFold::Collapsed, GroupFold::Expanded(depth)] { + let mut wb = base.clone(); + wb.active_view_mut().set_group_fold("Date", "2024", fold); + wb.active_view_mut().set_expand_depth("Date", n); + let text = format_md(&wb); + let back = parse_md(&text).unwrap_or_else(|e| panic!("{e}\n{text}")); + assert_eq!( + back.active_view().group_fold("Date", "2024"), + Some(fold), + "{text}" + ); + assert_eq!(back.active_view().expand_depth("Date"), n, "{text}"); + } + } +} diff --git a/crates/improvise-protocol/src/basis.rs b/crates/improvise-protocol/src/basis.rs index 4884e5be..aa12a7ff 100644 --- a/crates/improvise-protocol/src/basis.rs +++ b/crates/improvise-protocol/src/basis.rs @@ -64,6 +64,9 @@ pub enum Rejection { /// A [`ClientFrame::Save`](crate::ClientFrame::Save) whose write failed. /// No message: [`Rejection`] is `Copy` and an I/O error's text is not. SaveFailed, + /// A [`ClientFrame::DownloadCsv`](crate::ClientFrame::DownloadCsv) failed + /// to render the view (improvise-2mqz). Typically the view was not found. + ExportFailed, } /// The basis a client holds, folded from the stream like any other widget @@ -131,7 +134,7 @@ impl Rejection { | Self::ChainBroken(_) | Self::MutationRefused | Self::ReadOnly => true, - Self::NoDocumentPath | Self::SaveFailed => false, + Self::NoDocumentPath | Self::SaveFailed | Self::ExportFailed => false, } } @@ -160,7 +163,8 @@ impl Rejection { Self::MutationRefused => Self::ReadOnly, Self::ReadOnly => Self::NoDocumentPath, Self::NoDocumentPath => Self::SaveFailed, - Self::SaveFailed => Self::DocumentReplaced, + Self::SaveFailed => Self::ExportFailed, + Self::ExportFailed => Self::DocumentReplaced, } } } diff --git a/crates/improvise-protocol/src/client.rs b/crates/improvise-protocol/src/client.rs index d443368e..6b8cefd2 100644 --- a/crates/improvise-protocol/src/client.rs +++ b/crates/improvise-protocol/src/client.rs @@ -514,6 +514,7 @@ mod tests { live.fold(&Command::Project(Projection::CategoryTree( CategoryTreeUpdate::Categories(vec![CategoryRow { name: "Region".into(), + display: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }]), @@ -523,11 +524,13 @@ mod tests { ]))); live.fold(&Command::Project(Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), kind: crate::CategoryKind::Regular, axis: Axis::Row, }]))); live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row)], }]))); live.fold(&Command::SetSelected { row: 1, col: 1 }); diff --git a/crates/improvise-protocol/src/face.rs b/crates/improvise-protocol/src/face.rs new file mode 100644 index 00000000..5173b575 --- /dev/null +++ b/crates/improvise-protocol/src/face.rs @@ -0,0 +1,682 @@ +//! Faces: appearance named by role, and a theme that binds roles to values +//! (ADR 004). The reasoning is `context/code/protocol.md` §10. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::de::{self, Deserializer, Visitor}; +use serde::{Deserialize, Serialize, Serializer}; + +use crate::render::WizardFieldKind; +use crate::{AppMode, Panel}; +use improvise_core::view::Axis; + +/// One of the sixteen palette slots: "whatever THIS display calls that slot". +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AnsiSlot { + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + White, + BrightBlack, + BrightRed, + BrightGreen, + BrightYellow, + BrightBlue, + BrightMagenta, + BrightCyan, + BrightWhite, +} + +impl AnsiSlot { + pub const ALL: [AnsiSlot; 16] = [ + AnsiSlot::Black, + AnsiSlot::Red, + AnsiSlot::Green, + AnsiSlot::Yellow, + AnsiSlot::Blue, + AnsiSlot::Magenta, + AnsiSlot::Cyan, + AnsiSlot::White, + AnsiSlot::BrightBlack, + AnsiSlot::BrightRed, + AnsiSlot::BrightGreen, + AnsiSlot::BrightYellow, + AnsiSlot::BrightBlue, + AnsiSlot::BrightMagenta, + AnsiSlot::BrightCyan, + AnsiSlot::BrightWhite, + ]; + + /// The slot's number in the 256-colour palette, 0–15. + pub fn index(self) -> u8 { + self as u8 + } + + /// The spelling a theme file uses. + pub fn name(self) -> &'static str { + match self { + AnsiSlot::Black => "black", + AnsiSlot::Red => "red", + AnsiSlot::Green => "green", + AnsiSlot::Yellow => "yellow", + AnsiSlot::Blue => "blue", + AnsiSlot::Magenta => "magenta", + AnsiSlot::Cyan => "cyan", + AnsiSlot::White => "white", + AnsiSlot::BrightBlack => "bright-black", + AnsiSlot::BrightRed => "bright-red", + AnsiSlot::BrightGreen => "bright-green", + AnsiSlot::BrightYellow => "bright-yellow", + AnsiSlot::BrightBlue => "bright-blue", + AnsiSlot::BrightMagenta => "bright-magenta", + AnsiSlot::BrightCyan => "bright-cyan", + AnsiSlot::BrightWhite => "bright-white", + } + } + + pub fn from_name(name: &str) -> Option { + Self::ALL.into_iter().find(|slot| slot.name() == name) + } +} + +/// A colour attribute. `Default`, `Named` and `Indexed` are palette-relative; +/// `Rgb` is absolute, and the shipped theme never uses it (ADR 004 clause 4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Colour { + Default, + Named(AnsiSlot), + Indexed(u8), + Rgb(u8, u8, u8), +} + +impl Colour { + /// True for the one arm that opts a viewer out of their own palette. + pub fn is_absolute(self) -> bool { + match self { + Colour::Rgb(..) => true, + Colour::Default | Colour::Named(_) | Colour::Indexed(_) => false, + } + } +} + +impl Serialize for Colour { + fn serialize(&self, s: S) -> Result { + match *self { + Colour::Default => s.serialize_str("default"), + Colour::Named(slot) => s.serialize_str(slot.name()), + Colour::Indexed(n) => s.serialize_u8(n), + Colour::Rgb(r, g, b) => s.serialize_str(&format!("#{r:02x}{g:02x}{b:02x}")), + } + } +} + +struct ColourVisitor; + +impl Visitor<'_> for ColourVisitor { + type Value = Colour; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("\"default\", a slot name such as \"green\", 0-255, or \"#rrggbb\"") + } + + fn visit_u64(self, n: u64) -> Result { + u8::try_from(n) + .map(Colour::Indexed) + .map_err(|_| E::custom(format!("colour index {n} is outside 0-255"))) + } + + fn visit_str(self, s: &str) -> Result { + parse_colour(s).ok_or_else(|| E::invalid_value(de::Unexpected::Str(s), &self)) + } +} + +fn parse_colour(s: &str) -> Option { + match s.strip_prefix('#') { + Some(hex) if hex.len() == 6 && hex.bytes().all(|b| b.is_ascii_hexdigit()) => { + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some(Colour::Rgb(byte(0)?, byte(2)?, byte(4)?)) + } + Some(_) => None, + None if s == "default" => Some(Colour::Default), + None => AnsiSlot::from_name(s).map(Colour::Named), + } +} + +impl<'de> Deserialize<'de> for Colour { + fn deserialize>(d: D) -> Result { + d.deserialize_any(ColourVisitor) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Weight { + Normal, + Bold, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Slant { + Normal, + Italic, +} + +/// The text-entry modes, one per `MinibufferConfig`-carrying `AppMode` arm +/// except that cell editing is one role over two layouts. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum TextEntry { + Edit, + Formula, + Command, + CategoryAdd, + ItemAdd, + Export, + Save, +} + +impl TextEntry { + pub const ALL: [TextEntry; 7] = [ + TextEntry::Edit, + TextEntry::Formula, + TextEntry::Command, + TextEntry::CategoryAdd, + TextEntry::ItemAdd, + TextEntry::Export, + TextEntry::Save, + ]; + + /// The text-entry role of `mode`, or `None` for a mode that takes no text. + pub fn of(mode: &AppMode) -> Option { + match mode { + AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Some(TextEntry::Edit), + AppMode::FormulaEdit { .. } => Some(TextEntry::Formula), + AppMode::CommandMode { .. } => Some(TextEntry::Command), + AppMode::CategoryAdd { .. } => Some(TextEntry::CategoryAdd), + AppMode::ItemAdd { .. } => Some(TextEntry::ItemAdd), + AppMode::ExportPrompt { .. } => Some(TextEntry::Export), + AppMode::SavePrompt { .. } => Some(TextEntry::Save), + AppMode::Normal + | AppMode::RecordsNormal + | AppMode::FormulaPanel + | AppMode::CategoryPanel + | AppMode::ViewPanel + | AppMode::TileSelect + | AppMode::ImportWizard + | AppMode::Help + | AppMode::Quit => None, + } + } + + fn name(self) -> &'static str { + match self { + TextEntry::Edit => "edit", + TextEntry::Formula => "formula", + TextEntry::Command => "command", + TextEntry::CategoryAdd => "category-add", + TextEntry::ItemAdd => "item-add", + TextEntry::Export => "export", + TextEntry::Save => "save", + } + } +} + +const AXES: [Axis; 5] = [ + Axis::Row, + Axis::Column, + Axis::Page, + Axis::Filter, + Axis::None, +]; + +const FIELD_KINDS: [WizardFieldKind; 4] = [ + WizardFieldKind::Category, + WizardFieldKind::Measure, + WizardFieldKind::TimeCategory, + WizardFieldKind::Label, +]; + +fn axis_name(axis: Axis) -> &'static str { + match axis { + Axis::Row => "row", + Axis::Column => "column", + Axis::Page => "page", + Axis::Filter => "filter", + Axis::None => "none", + } +} + +fn field_kind_name(kind: WizardFieldKind) -> &'static str { + match kind { + WizardFieldKind::Category => "category", + WizardFieldKind::Measure => "measure", + WizardFieldKind::TimeCategory => "time", + WizardFieldKind::Label => "label", + } +} + +/// A role something is drawn in. Its `name` is a compatibility surface once a +/// theme file carries it (ADR 004 clause 9), so never rename one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Face { + Default, + Dim, + Axis(Axis), + WizardField(WizardFieldKind), + Mode, + ModeResting, + ModeTextEntry, + ModeTextEntryOf(TextEntry), + ModePanel(Panel), + ModeOverlay, + ModeTransient, + ModeTileSelect, + MinibufferOf(TextEntry), +} + +impl Face { + /// Every face, parents before children. + pub fn all() -> Vec { + let mut out = vec![Face::Default, Face::Dim]; + out.extend(AXES.map(Face::Axis)); + out.extend(FIELD_KINDS.map(Face::WizardField)); + out.extend([Face::Mode, Face::ModeResting, Face::ModeTextEntry]); + out.extend(TextEntry::ALL.map(Face::ModeTextEntryOf)); + out.extend(Panel::ALL.map(Face::ModePanel)); + out.extend([Face::ModeOverlay, Face::ModeTransient, Face::ModeTileSelect]); + out.extend(TextEntry::ALL.map(Face::MinibufferOf)); + out + } + + /// The face this one inherits every attribute it does not state from. + pub fn parent(self) -> Option { + match self { + Face::Default => None, + Face::Dim | Face::Mode => Some(Face::Default), + Face::Axis(Axis::None) | Face::WizardField(WizardFieldKind::Label) => Some(Face::Dim), + Face::Axis(_) | Face::WizardField(_) => Some(Face::Default), + Face::ModeResting + | Face::ModeTextEntry + | Face::ModePanel(_) + | Face::ModeOverlay + | Face::ModeTransient + | Face::ModeTileSelect => Some(Face::Mode), + Face::ModeTextEntryOf(_) | Face::MinibufferOf(_) => Some(Face::ModeTextEntry), + } + } + + /// The spelling a theme file keys this face by. + pub fn name(self) -> String { + match self { + Face::Default => "default".into(), + Face::Dim => "dim".into(), + Face::Axis(axis) => format!("axis.{}", axis_name(axis)), + Face::WizardField(kind) => format!("wizard.field.{}", field_kind_name(kind)), + Face::Mode => "mode".into(), + Face::ModeResting => "mode.resting".into(), + Face::ModeTextEntry => "mode.text-entry".into(), + Face::ModeTextEntryOf(entry) => format!("mode.text-entry.{}", entry.name()), + Face::ModePanel(panel) => format!("mode.panel.{}", panel.name()), + Face::ModeOverlay => "mode.overlay".into(), + Face::ModeTransient => "mode.transient".into(), + Face::ModeTileSelect => "mode.tile-select".into(), + Face::MinibufferOf(entry) => format!("minibuffer.{}", entry.name()), + } + } + + pub fn from_name(name: &str) -> Option { + Face::all().into_iter().find(|face| face.name() == name) + } + + /// The face the mode indicator is drawn in. + pub fn of_mode(mode: &AppMode) -> Face { + match mode { + AppMode::Normal | AppMode::RecordsNormal => Face::ModeResting, + AppMode::FormulaPanel => Face::ModePanel(Panel::Formula), + AppMode::CategoryPanel => Face::ModePanel(Panel::Category), + AppMode::ViewPanel => Face::ModePanel(Panel::View), + AppMode::ImportWizard | AppMode::Help => Face::ModeOverlay, + AppMode::Quit => Face::ModeTransient, + AppMode::TileSelect => Face::ModeTileSelect, + AppMode::Editing { .. } + | AppMode::RecordsEditing { .. } + | AppMode::FormulaEdit { .. } + | AppMode::CommandMode { .. } + | AppMode::CategoryAdd { .. } + | AppMode::ItemAdd { .. } + | AppMode::ExportPrompt { .. } + | AppMode::SavePrompt { .. } => TextEntry::of(mode) + .map(Face::ModeTextEntryOf) + .unwrap_or(Face::ModeTextEntry), + } + } + + /// The face a text-entry mode's minibuffer is drawn in. + pub fn of_minibuffer(mode: &AppMode) -> Option { + TextEntry::of(mode).map(Face::MinibufferOf) + } +} + +impl Serialize for Face { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.name()) + } +} + +impl<'de> Deserialize<'de> for Face { + fn deserialize>(d: D) -> Result { + let name = String::deserialize(d)?; + Face::from_name(&name).ok_or_else(|| de::Error::custom(format!("unknown face {name:?}"))) + } +} + +/// What a theme states about one face. `None` inherits from the parent; +/// `Some(Colour::Default)` states "the surface" and does not inherit. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FaceSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weight: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub underline: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reverse: Option, +} + +impl FaceSpec { + fn or(self, parent: FaceSpec) -> FaceSpec { + FaceSpec { + foreground: self.foreground.or(parent.foreground), + background: self.background.or(parent.background), + weight: self.weight.or(parent.weight), + slant: self.slant.or(parent.slant), + underline: self.underline.or(parent.underline), + reverse: self.reverse.or(parent.reverse), + } + } + + fn fg(colour: Colour) -> FaceSpec { + FaceSpec { + foreground: Some(colour), + ..FaceSpec::default() + } + } + + fn bg(colour: Colour) -> FaceSpec { + FaceSpec { + background: Some(colour), + ..FaceSpec::default() + } + } +} + +/// A face with every attribute concrete: what a front end translates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResolvedFace { + pub foreground: Colour, + pub background: Colour, + pub weight: Weight, + pub slant: Slant, + pub underline: bool, + pub reverse: bool, +} + +/// Faces bound to values. Client-local: never a `Command`, never in +/// `ViewState`, never projected, never in `.improv` (ADR 004 clause 7). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Theme { + pub faces: BTreeMap, +} + +impl Theme { + /// The theme improvise ships: today's terminal rendering, palette-relative + /// throughout, so the user's terminal theme still applies. + pub fn shipped() -> Theme { + use AnsiSlot::*; + let named = Colour::Named; + let minibuffer = |accent| FaceSpec { + foreground: Some(named(accent)), + background: Some(Colour::Indexed(235)), + weight: Some(Weight::Bold), + ..FaceSpec::default() + }; + let faces = [ + (Face::Dim, FaceSpec::fg(named(BrightBlack))), + (Face::Axis(Axis::Row), FaceSpec::fg(named(Green))), + (Face::Axis(Axis::Column), FaceSpec::fg(named(Blue))), + (Face::Axis(Axis::Page), FaceSpec::fg(named(Magenta))), + (Face::Axis(Axis::Filter), FaceSpec::fg(named(Yellow))), + ( + Face::WizardField(WizardFieldKind::Category), + FaceSpec::fg(named(Green)), + ), + ( + Face::WizardField(WizardFieldKind::Measure), + FaceSpec::fg(named(Cyan)), + ), + ( + Face::WizardField(WizardFieldKind::TimeCategory), + FaceSpec::fg(named(Magenta)), + ), + ( + Face::Mode, + FaceSpec { + foreground: Some(named(Black)), + background: Some(named(BrightBlack)), + ..FaceSpec::default() + }, + ), + ( + Face::ModeTextEntryOf(TextEntry::Edit), + FaceSpec::bg(named(Green)), + ), + ( + Face::ModeTextEntryOf(TextEntry::Command), + FaceSpec::bg(named(Yellow)), + ), + (Face::ModeTileSelect, FaceSpec::bg(named(Magenta))), + (Face::MinibufferOf(TextEntry::Edit), minibuffer(Green)), + (Face::MinibufferOf(TextEntry::ItemAdd), minibuffer(Green)), + ( + Face::MinibufferOf(TextEntry::CategoryAdd), + minibuffer(Green), + ), + (Face::MinibufferOf(TextEntry::Formula), minibuffer(Cyan)), + (Face::MinibufferOf(TextEntry::Command), minibuffer(Yellow)), + (Face::MinibufferOf(TextEntry::Export), minibuffer(Yellow)), + (Face::MinibufferOf(TextEntry::Save), minibuffer(Yellow)), + ]; + Theme { + faces: faces.into_iter().collect(), + } + } + + /// Walk `face`'s inheritance chain once, so no front end ever does. + pub fn resolve(&self, face: Face) -> ResolvedFace { + let stated = std::iter::successors(Some(face), |f| f.parent()) + .filter_map(|f| self.faces.get(&f).copied()) + .fold(FaceSpec::default(), FaceSpec::or); + ResolvedFace { + foreground: stated.foreground.unwrap_or(Colour::Default), + background: stated.background.unwrap_or(Colour::Default), + weight: stated.weight.unwrap_or(Weight::Normal), + slant: stated.slant.unwrap_or(Slant::Normal), + underline: stated.underline.unwrap_or(false), + reverse: stated.reverse.unwrap_or(false), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_face_name_is_distinct_and_reads_back_as_that_face() { + let all = Face::all(); + for face in &all { + assert_eq!(Face::from_name(&face.name()), Some(*face), "{face:?}"); + } + let mut names: Vec = all.iter().map(|f| f.name()).collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), all.len(), "two faces share a name"); + } + + #[test] + fn every_parent_is_a_face_listed_before_its_child() { + let all = Face::all(); + for (i, face) in all.iter().enumerate() { + if let Some(parent) = face.parent() { + let at = all.iter().position(|f| *f == parent); + assert!(at.is_some_and(|p| p < i), "{face:?}'s parent {parent:?}"); + } + } + } + + /// ADR 004 clause 4: the shipped theme keeps the user's terminal palette. + #[test] + fn the_shipped_theme_states_no_absolute_colour() { + let theme = Theme::shipped(); + for face in Face::all() { + let resolved = theme.resolve(face); + assert!(!resolved.foreground.is_absolute(), "{}", face.name()); + assert!(!resolved.background.is_absolute(), "{}", face.name()); + } + } + + /// ADR 004: `minibuffer.*` inherits `mode.text-entry` directly, so a theme + /// that restyles text entry reaches every prompt. `context/test-evidence/protocol.md`. + #[test] + fn a_theme_that_restyles_text_entry_reaches_every_minibuffer() { + let mut theme = Theme::shipped(); + let italic = FaceSpec { + slant: Some(Slant::Italic), + ..FaceSpec::default() + }; + theme.faces.insert(Face::ModeTextEntry, italic); + for entry in TextEntry::ALL { + let got = theme.resolve(Face::MinibufferOf(entry)); + assert_eq!(got.slant, Slant::Italic, "{entry:?}"); + } + } + + /// A collision defeats the reason the axis faces exist (improvise-rml). + #[test] + fn every_axis_gets_its_own_accent_in_the_shipped_theme() { + let theme = Theme::shipped(); + let accents = AXES.map(|axis| theme.resolve(Face::Axis(axis))); + for (i, a) in accents.iter().enumerate() { + for b in &accents[i + 1..] { + assert_ne!(a, b, "two axes share an accent"); + } + } + } + + /// A child overrides what it states and inherits what it does not, from + /// the nearest ancestor that states it — `context/test-evidence/protocol.md`. + #[test] + fn resolution_takes_each_attribute_from_the_nearest_face_that_states_it() { + let named = Colour::Named; + let theme = Theme { + faces: [ + ( + Face::Default, + FaceSpec { + foreground: Some(named(AnsiSlot::White)), + slant: Some(Slant::Italic), + ..FaceSpec::default() + }, + ), + ( + Face::Mode, + FaceSpec { + foreground: Some(named(AnsiSlot::Black)), + background: Some(named(AnsiSlot::Blue)), + ..FaceSpec::default() + }, + ), + ( + Face::ModeTextEntryOf(TextEntry::Save), + FaceSpec { + background: Some(Colour::Default), + underline: Some(true), + ..FaceSpec::default() + }, + ), + ] + .into_iter() + .collect(), + }; + let got = theme.resolve(Face::ModeTextEntryOf(TextEntry::Save)); + assert_eq!( + got, + ResolvedFace { + foreground: named(AnsiSlot::Black), + background: Colour::Default, + weight: Weight::Normal, + slant: Slant::Italic, + underline: true, + reverse: false, + } + ); + let sibling = theme.resolve(Face::ModeTextEntryOf(TextEntry::Export)); + assert_eq!(sibling.background, named(AnsiSlot::Blue)); + } + + #[test] + fn a_theme_survives_its_own_json() { + let theme = Theme::shipped(); + let text = serde_json::to_string_pretty(&theme).unwrap(); + assert!(text.contains("\"minibuffer.category-add\""), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), theme); + } + + #[test] + fn a_colour_is_spelled_for_a_person_editing_the_file() { + let cases = [ + (Colour::Default, "\"default\""), + (Colour::Named(AnsiSlot::BrightBlack), "\"bright-black\""), + (Colour::Indexed(235), "235"), + (Colour::Rgb(0x3f, 0x7a, 0x4d), "\"#3f7a4d\""), + ]; + for (colour, json) in cases { + assert_eq!(serde_json::to_string(&colour).unwrap(), json); + assert_eq!(serde_json::from_str::(json).unwrap(), colour); + } + for bad in ["\"grene\"", "\"#3f7a4\"", "256", "\"#gggggg\""] { + assert!(serde_json::from_str::(bad).is_err(), "{bad}"); + } + } + + #[test] + fn a_misspelt_face_or_attribute_is_refused_rather_than_ignored() { + let face = r#"{"faces":{"axis.rwo":{"foreground":"green"}}}"#; + assert!(serde_json::from_str::(face).is_err()); + let attr = r#"{"faces":{"axis.row":{"forground":"green"}}}"#; + assert!(serde_json::from_str::(attr).is_err()); + } + + #[test] + fn every_text_entry_mode_has_a_text_entry_role_and_no_other_mode_does() { + for mode in AppMode::all() { + assert_eq!( + TextEntry::of(&mode).is_some(), + mode.minibuffer().is_some(), + "{mode:?}" + ); + } + } +} diff --git a/crates/improvise-protocol/src/frame.rs b/crates/improvise-protocol/src/frame.rs index 8b793750..29cfc183 100644 --- a/crates/improvise-protocol/src/frame.rs +++ b/crates/improvise-protocol/src/frame.rs @@ -23,6 +23,12 @@ pub enum ClientFrame { code: KeyCode, modifiers: KeyModifiers, }, + /// Ask for document bytes named by SavePrompt; the browser downloads them + /// via Blob + `` (improvise-2mqz). + DownloadDocument { filename: String }, + /// Ask for active-view CSV bytes named by ExportPrompt; the browser downloads + /// them via Blob + `` (improvise-2mqz). + DownloadCsv { filename: String }, } impl ClientFrame { @@ -55,6 +61,12 @@ pub enum ServerFrame { /// The asker's mutating frame landed whole. Carries no state: everything /// it moved arrived as projections first — `context/code/protocol.md` §4. Accepted, + /// Downloadable `.improv` bytes addressed to the asker alone; the filename + /// is echoed from its request (improvise-2mqz). + DocumentBytes { filename: String, bytes: Vec }, + /// Downloadable active-view CSV bytes addressed to the asker alone; the + /// filename is echoed from its request (improvise-2mqz). + CsvBytes { filename: String, bytes: Vec }, } impl ServerFrame { @@ -83,6 +95,12 @@ mod tests { )), ClientFrame::key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), ClientFrame::Save, + ClientFrame::DownloadDocument { + filename: "doc.improv".into(), + }, + ClientFrame::DownloadCsv { + filename: "export.csv".into(), + }, ] { let json = serde_json::to_string(&frame).expect("serializes"); assert_eq!( @@ -105,6 +123,14 @@ mod tests { ))]), ServerFrame::Rejected(Rejection::LayoutMoved), ServerFrame::Accepted, + ServerFrame::DocumentBytes { + filename: "test.improv".into(), + bytes: b"v2025-04-09\n# Test\n".to_vec(), + }, + ServerFrame::CsvBytes { + filename: "export.csv".into(), + bytes: b"col1,col2\na,b\n".to_vec(), + }, ] { let json = serde_json::to_string(&frame).expect("serializes"); assert_eq!( diff --git a/crates/improvise-protocol/src/keymap.rs b/crates/improvise-protocol/src/keymap.rs index c16f5a3a..4bd2e2d9 100644 --- a/crates/improvise-protocol/src/keymap.rs +++ b/crates/improvise-protocol/src/keymap.rs @@ -927,11 +927,13 @@ impl Keymaps { ), ); + // LocalThenRequest cannot live in a sequence, so these commands own + // their buffer clears and are rebound directly (improvise-2mqz). set.insert( ModeKey::ExportPrompt, Arc::new( TextEntry::new(&AppMode::export_prompt(), "normal", global.clone()) - .commit(KeyCode::Enter, "commit-export", vec![]) + .rebind(KeyCode::Enter, "commit-export") .finish(), ), ); @@ -940,7 +942,7 @@ impl Keymaps { ModeKey::SavePrompt, Arc::new( TextEntry::new(&AppMode::save_prompt(), "normal", global.clone()) - .commit(KeyCode::Enter, "commit-save", vec![]) + .rebind(KeyCode::Enter, "commit-save") .finish(), ), ); diff --git a/crates/improvise-protocol/src/lib.rs b/crates/improvise-protocol/src/lib.rs index 6e535f44..71dabf12 100644 --- a/crates/improvise-protocol/src/lib.rs +++ b/crates/improvise-protocol/src/lib.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; pub mod basis; pub mod client; +pub mod face; pub mod frame; pub mod help; pub mod key; @@ -23,6 +24,7 @@ mod vocabulary_is_composed; pub use basis::{Basis, BasisCache, Rejection}; pub use client::{ClientState, Sent}; +pub use face::{Face, Theme}; pub use frame::{ClientFrame, ServerFrame}; pub use help::{HelpLine, Hint, hint_line, hints, mode_label}; pub use key::{KeyCode, KeyEvent, KeyModifiers}; @@ -103,7 +105,7 @@ pub struct MinibufferConfig { /// One of the three side panels. Paired with [`AppMode::panel`], which names /// the panel a mode is focused in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum Panel { Formula, Category, @@ -394,7 +396,7 @@ impl AppMode { minibuf: MinibufferConfig { buffer_key: "category".into(), prompt: "new category: ".into(), - color: MinibufColor::Yellow, + color: MinibufColor::Green, }, } } diff --git a/crates/improvise-protocol/src/reduce.rs b/crates/improvise-protocol/src/reduce.rs index e4c0e053..051a738d 100644 --- a/crates/improvise-protocol/src/reduce.rs +++ b/crates/improvise-protocol/src/reduce.rs @@ -556,10 +556,12 @@ mod tests { Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])), Command::Project(Projection::ViewList(vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row)], }])), Command::Project(Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), kind: crate::CategoryKind::Regular, axis: Axis::Page, }])), diff --git a/crates/improvise-protocol/src/render.rs b/crates/improvise-protocol/src/render.rs index 8c8ba510..0e9e7ef3 100644 --- a/crates/improvise-protocol/src/render.rs +++ b/crates/improvise-protocol/src/render.rs @@ -214,6 +214,9 @@ pub struct FrameGroup { /// Which header level opened it — an index into [`AxisFrame::cats`]. Runs /// nest, so membership is per level (improvise-e265). pub level: usize, + /// The group's depth in its category's hierarchy, 0 for a root. Runs nest + /// by `(level, depth)`: `context/code/protocol.md` §3a (improvise-felo.6). + pub depth: usize, pub name: String, pub collapsed: bool, } @@ -223,10 +226,11 @@ pub struct FrameGroup { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum FrameEntry { Group(FrameGroup), - /// The run at `level`, and every run inside it, ends here — `AxisEntry::GroupEnd` - /// on the wire (improvise-gd83, e265). Nothing is drawn for it. + /// The run at `(level, depth)`, and every run inside it, ends here — + /// `AxisEntry::GroupEnd` on the wire (improvise-gd83, e265). Nothing is drawn. GroupEnd { level: usize, + depth: usize, }, /// One label per header level, outermost first. Data indices — the /// `row`/`col` of every other field on [`GridCache`] — count only these. @@ -279,7 +283,7 @@ impl TryFrom for AxisFrame { .iter() .filter_map(|e| match e { FrameEntry::Group(g) => Some(g.level), - FrameEntry::GroupEnd { level } => Some(*level), + FrameEntry::GroupEnd { level, .. } => Some(*level), FrameEntry::Item { .. } => None, }) .find(|level| *level >= raw.cats.len()); @@ -418,20 +422,22 @@ impl AxisFrame { .collect() } - /// The innermost group each data item sits in, in order — one pass. A - /// group at L closes deeper runs; so does a `GroupEnd` at L (improvise-e265). + /// The innermost group each data item sits in, in order — one pass. A group + /// at `(level, depth)` closes deeper runs; so does a `GroupEnd` there (§3a). pub fn groups(&self) -> Vec> { - let mut open: Vec> = Vec::new(); + let mut open: Vec<&FrameGroup> = Vec::new(); + let close = |open: &mut Vec<&FrameGroup>, key: (usize, usize)| { + open.truncate(open.partition_point(|g| (g.level, g.depth) < key)) + }; let mut out = Vec::new(); for entry in &self.entries { match entry { FrameEntry::Group(g) => { - open.truncate(g.level); - open.resize(g.level, None); - open.push(Some(g)); + close(&mut open, (g.level, g.depth)); + open.push(g); } - FrameEntry::GroupEnd { level } => open.truncate(*level), - FrameEntry::Item { .. } => out.push(open.iter().rev().find_map(|g| *g)), + FrameEntry::GroupEnd { level, depth } => close(&mut open, (*level, *depth)), + FrameEntry::Item { .. } => out.push(open.last().copied()), } } out @@ -753,6 +759,8 @@ impl WidgetCache for GridCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CategoryRow { pub name: String, + /// What the panel shows; `name` stays what a command names (ADR 007). + pub display: String, pub items: Vec, pub axis: Axis, } @@ -851,6 +859,8 @@ impl WidgetCache for FormulaCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ViewSummary { pub name: String, + /// What the view panel shows; `name` stays what a command names. + pub display: String, /// `(category, axis)` for the categories this view displays. pub axes: Vec<(String, Axis)>, } @@ -875,6 +885,8 @@ impl WidgetCache for ViewListCache { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TileEntry { pub category: String, + /// What the tile shows; `category` stays what a command names. + pub display: String, pub axis: Axis, pub kind: CategoryKind, } @@ -927,7 +939,7 @@ pub enum WizardStepKind { /// What kind of thing a proposed field is. The semantic half of a colour /// decision; the terminal mapping lives in `draw.rs` (gotcha 26). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub enum WizardFieldKind { Category, Measure, @@ -955,6 +967,9 @@ pub struct WizardDateField { /// what is drawn, so the substitution is made once, here. pub format: String, pub components: Vec<(String, bool)>, + /// What the chosen components become, as a sentence: one hierarchical + /// category, or a split (improvise-8jk6.3). Worded once, in the projection. + pub shape: String, } /// Several source records sharing every coordinate (improvise-cgqt). Data, @@ -1066,6 +1081,7 @@ mod tests { fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry { TileEntry { category: category.to_string(), + display: category.to_string(), axis, kind, } @@ -1150,6 +1166,7 @@ mod tests { fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow { CategoryRow { name: name.into(), + display: name.into(), items: items.iter().map(|s| (*s).to_string()).collect(), axis, } @@ -1349,6 +1366,7 @@ mod tests { FrameEntry::item(vec!["A".into(), "y".into()]), FrameEntry::Group(FrameGroup { level: 0, + depth: 0, name: "Late".into(), collapsed: true, }), @@ -1362,6 +1380,7 @@ mod tests { fn repeated_group_frame() -> AxisFrame { let q1 = || FrameGroup { level: 1, + depth: 0, name: "Q1".into(), collapsed: false, }; @@ -1388,12 +1407,13 @@ mod tests { entries: vec![ FrameEntry::Group(FrameGroup { level: 0, + depth: 0, name: "Q1".into(), collapsed: false, }), FrameEntry::item(vec!["Jan".into()]), FrameEntry::item(vec!["Feb".into()]), - FrameEntry::GroupEnd { level: 0 }, + FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Sep".into()]), ], } @@ -1406,6 +1426,7 @@ mod tests { let group = |level: usize, name: &str| { FrameEntry::Group(FrameGroup { level, + depth: 0, name: name.into(), collapsed: true, }) @@ -1416,7 +1437,7 @@ mod tests { entries: vec![ group(0, "Upper"), group(1, "Q1"), - FrameEntry::GroupEnd { level: 1 }, + FrameEntry::GroupEnd { level: 1, depth: 0 }, ], }; let json = serde_json::to_string(&frame).unwrap(); @@ -1424,10 +1445,13 @@ mod tests { assert_eq!(back, frame); for (from, to) in [ ( - r#""level":1,"name":"Q1""#, - r#""level":4294967295,"name":"Q1""#, + r#""level":1,"depth":0,"name":"Q1""#, + r#""level":4294967295,"depth":0,"name":"Q1""#, + ), + ( + r#"{"GroupEnd":{"level":1,"depth":0}}"#, + r#"{"GroupEnd":{"level":2,"depth":0}}"#, ), - (r#"{"GroupEnd":{"level":1}}"#, r#"{"GroupEnd":{"level":2}}"#), ] { let bad = json.replacen(from, to, 1); assert_ne!(bad, json, "the fault {to} was injected"); @@ -1440,6 +1464,45 @@ mod tests { } } + /// improvise-felo.6: runs nest by `(level, depth)` inside one category, so + /// the end of a quarter leaves its year open. See the evidence file. + #[test] + fn a_quarters_end_leaves_its_year_open_on_the_same_level() { + let group = |depth: usize, name: &str| { + FrameEntry::Group(FrameGroup { + level: 0, + depth, + name: name.into(), + collapsed: false, + }) + }; + let frame = AxisFrame { + cats: vec!["Date".into()], + first: 0, + entries: vec![ + group(0, "2024"), + group(1, "Q1"), + FrameEntry::item(vec!["Jan".into()]), + FrameEntry::GroupEnd { level: 0, depth: 1 }, + FrameEntry::item(vec!["Direct".into()]), + group(1, "Q2"), + FrameEntry::item(vec!["Apr".into()]), + FrameEntry::GroupEnd { level: 0, depth: 0 }, + FrameEntry::item(vec!["Undated".into()]), + ], + }; + let names: Vec> = frame + .groups() + .into_iter() + .map(|g| g.map(|g| g.name.as_str())) + .collect(); + assert_eq!( + names, + vec![Some("Q1"), Some("2024"), Some("Q2"), None], + "Direct sits in 2024 after Q1 ends; Undated in nothing" + ); + } + /// improvise-gd83's property, asserted in the crate that owns the type. /// See `context/test-evidence/protocol.md`. #[test] @@ -1467,10 +1530,11 @@ mod tests { entries: vec![ FrameEntry::Group(FrameGroup { level: 0, + depth: 0, name: "Q1".into(), collapsed: true, }), - FrameEntry::GroupEnd { level: 0 }, + FrameEntry::GroupEnd { level: 0, depth: 0 }, FrameEntry::item(vec!["Sep".into()]), ], }; @@ -1748,12 +1812,14 @@ mod tests { let views = ViewListCache::folded(&[vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row)], }]]); assert_eq!(views.views[0].name, "Default"); let tiles = TileBarCache::folded(&[vec![TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]]); @@ -2148,10 +2214,12 @@ mod tests { Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::ViewList(vec![ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::None)], }]), Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]), @@ -2189,6 +2257,7 @@ mod tests { Projection::Formulas(vec!["Profit = Revenue - Cost".into()]), Projection::TileBar(vec![TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Page, kind: crate::CategoryKind::Regular, }]), diff --git a/crates/improvise-protocol/src/resolve.rs b/crates/improvise-protocol/src/resolve.rs index 1f9113d4..ea0d009d 100644 --- a/crates/improvise-protocol/src/resolve.rs +++ b/crates/improvise-protocol/src/resolve.rs @@ -27,6 +27,12 @@ pub enum KeyOutcome { /// Resolved here as a **request**: the caller sends the frame and folds /// nothing. Why it is not a `Command`: `context/code/clients.md`. Request(Request), + /// Fold commands locally, then send a request: mode changes here and + /// download bytes come from the server (improvise-2mqz). + LocalThenRequest { + cmds: Vec, + request: Request, + }, /// Bound to a command this client cannot compose. Forwarded to the host, /// which today answers nothing — `context/code/clients.md`. Upstream(KeyEvent), @@ -37,10 +43,16 @@ pub enum KeyOutcome { /// What a key may ask its **host** to do, as opposed to what it may compose. /// Narrow on purpose — the argument is in `context/code/clients.md`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Request { /// Write the document back where the host keeps it (improvise-368h). Save, + /// Get the document as bytes for download (improvise-2mqz). The filename + /// is what the user typed in the SavePrompt minibuffer. + DownloadDocument { filename: String }, + /// Get the active view as CSV bytes for download (improvise-2mqz). The filename + /// is what the user typed in the ExportPrompt minibuffer. + DownloadCsv { filename: String }, } impl Request { @@ -49,6 +61,8 @@ impl Request { pub fn frame(self) -> ClientFrame { match self { Request::Save => ClientFrame::Save, + Request::DownloadDocument { filename } => ClientFrame::DownloadDocument { filename }, + Request::DownloadCsv { filename } => ClientFrame::DownloadCsv { filename }, } } } @@ -92,6 +106,9 @@ pub fn resolve(state: &ClientState, event: KeyEvent) -> KeyOutcome { Binding::Cmd { name, args } => match step(state, event, name, args) { Step::Local(commands) | Step::Declined(commands) => KeyOutcome::of(commands), Step::Request(request) => KeyOutcome::Request(request), + Step::LocalThenRequest { cmds, request } => { + KeyOutcome::LocalThenRequest { cmds, request } + } Step::Upstream => KeyOutcome::Upstream(event), }, // All or nothing, each step resolved against the *unchanged* state. @@ -131,7 +148,7 @@ fn chord(state: &ClientState, event: KeyEvent, binding: Option<&Binding>) -> Key Step::Local(commands) | Step::Declined(commands) => Some(commands), // DISCARDED, which is why the guard refuses both classes rather // than counting them answered — `context/code/clients.md`. - Step::Request(_) | Step::Upstream => None, + Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => None, }, Some(Binding::Sequence(steps)) => sequence(state, event, steps), }; @@ -294,7 +311,7 @@ fn sequence( Step::Declined(said) => return Some(said), // A request is a frame of its own and a sequence is one frame, so // there is nowhere to put it. No binding does — clients.md. - Step::Request(_) | Step::Upstream => return None, + Step::Request(_) | Step::LocalThenRequest { .. } | Step::Upstream => return None, } } Some(commands) @@ -306,22 +323,31 @@ enum Step { Local(Vec), Declined(Vec), Request(Request), + /// Fold commands locally, THEN send a request. For commit-save/commit-export: + /// the mode change folds here, the download request goes to the server. + LocalThenRequest { + cmds: Vec, + request: Request, + }, Upstream, } /// One name, answered. Each list is checked before the next, so no name is in /// two of them and [`composed`] has no arm for any earlier one. fn step(state: &ClientState, event: KeyEvent, name: &str, args: &[String]) -> Step { - match requested(name) { - Some(request) => Step::Request(request), - None => match declined(name, args).or_else(|| refused_here(state, name)) { - Some(said) => Step::Declined(vec![Command::SetStatus(said)]), - None => match committed(state, name, args) { - Some(answer) => answer, - None => match composed(state, event, name, args) { - Some(commands) => Step::Local(commands), - None => Step::Upstream, - }, + if let Some(request) = requested(name) { + return Step::Request(request); + } + if let Some(step) = download_request(state, name) { + return step; + } + match declined(name, args).or_else(|| refused_here(state, name)) { + Some(said) => Step::Declined(vec![Command::SetStatus(said)]), + None => match committed(state, name, args) { + Some(answer) => answer, + None => match composed(state, event, name, args) { + Some(commands) => Step::Local(commands), + None => Step::Upstream, }, }, } @@ -365,6 +391,36 @@ fn requested(name: &str) -> Option { } } +/// Download requests that need to read a buffer and fold a mode change locally +/// before sending the request (improvise-2mqz). +fn download_request(state: &ClientState, name: &str) -> Option { + let (buffer_name, make_request): (&str, fn(String) -> Request) = match name { + "commit-save" => ("save", |f| Request::DownloadDocument { filename: f }), + "commit-export" => ("export", |f| Request::DownloadCsv { filename: f }), + _ => return None, + }; + let filename = read_buffer(&state.view, buffer_name).trim().to_string(); + if filename.is_empty() { + return Some(Step::Declined(vec![Command::SetStatus(format!( + "No filename given — not {}", + if name == "commit-save" { + "saved" + } else { + "exported" + } + ))])); + } + // This owns the clear and mode change because LocalThenRequest cannot be + // part of a Binding::Sequence (improvise-2mqz). + Some(Step::LocalThenRequest { + cmds: vec![ + write_buffer(buffer_name, String::new()), + Command::ChangeMode(AppMode::Normal), + ], + request: make_request(filename), + }) +} + /// The commands this client cannot compose and answers **out loud**; the one /// home for the wording. Why silence was worse: clients.md (improvise-rmvx). fn declined(name: &str, args: &[String]) -> Option { @@ -681,8 +737,8 @@ fn write_buffer(name: &str, value: String) -> Command { /// to decline by name. A dead room is worse than a locked door: clients.md. fn stranding(mode: &AppMode) -> Option<&'static str> { match mode { - AppMode::SavePrompt { .. } => Some("saving to a path"), - AppMode::ExportPrompt { .. } => Some("exporting"), + // SavePrompt and ExportPrompt were stranded before improvise-2mqz added + // DownloadDocument and DownloadCsv frames. Now they have wire forms. AppMode::CommandMode { .. } => Some("the command line"), _ => None, } @@ -1892,11 +1948,13 @@ mod tests { crate::CategoryTreeUpdate::Categories(vec![ crate::CategoryRow { name: "Region".into(), + display: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }, crate::CategoryRow { name: "Product".into(), + display: "Product".into(), items: vec!["Shirts".into()], axis: Axis::Column, }, @@ -1975,11 +2033,13 @@ mod tests { state.tiles.tiles = vec![ crate::TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Row, kind: crate::CategoryKind::Regular, }, crate::TileEntry { category: "Product".into(), + display: "Product".into(), axis: Axis::Column, kind: crate::CategoryKind::Regular, }, @@ -2334,10 +2394,12 @@ mod tests { state.fold(&Command::Project(Projection::ViewList(vec![ crate::ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![], }, crate::ViewSummary { name: "Plan".into(), + display: "Plan".into(), axes: vec![], }, ]))); @@ -2617,6 +2679,7 @@ mod tests { .into_iter() .map(|name| crate::CategoryRow { name: name.into(), + display: name.into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }) @@ -2635,6 +2698,7 @@ mod tests { .into_iter() .map(|name| crate::ViewSummary { name: name.into(), + display: name.into(), axes: Vec::new(), }) .collect(); @@ -2642,6 +2706,7 @@ mod tests { .into_iter() .map(|category| crate::TileEntry { category: category.into(), + display: category.into(), axis: Axis::Row, kind: crate::CategoryKind::Regular, }) @@ -2661,6 +2726,15 @@ mod tests { .insert("category".into(), "Product".into()); state.view.buffers.insert("item".into(), "Shirts".into()); state.view.buffers.insert("edit".into(), "42".into()); + // commit-save and commit-export need their buffers filled (improvise-2mqz). + state + .view + .buffers + .insert("save".into(), "data.improv".into()); + state + .view + .buffers + .insert("export".into(), "data.csv".into()); state } @@ -2678,7 +2752,7 @@ mod tests { fn heard(state: &mut ClientState, outcome: KeyOutcome) -> Heard { let commands = match outcome { KeyOutcome::Upstream(_) | KeyOutcome::Ignored => return Heard::Silent, - KeyOutcome::Request(_) => return Heard::Acts, + KeyOutcome::Request(_) | KeyOutcome::LocalThenRequest { .. } => return Heard::Acts, KeyOutcome::Local(commands) => commands, }; let quiet = |view: &ViewState| ViewState { @@ -2826,8 +2900,6 @@ mod tests { // Reached only from a mode `declined` refuses at the door, so a // browser is never standing in front of these. "COMMAND: Enter execute-command Silent", - "EXPORT: Enter commit-export Silent", - "SAVE AS: Enter commit-save Silent", // Honestly unavailable, and saying so in `declined`'s words. "NORMAL: > drill-into-cell Declines", "NORMAL: : enter-mode Declines", @@ -3482,37 +3554,45 @@ mod tests { ); } - /// The refusal above is only half the property: the bug guarded is - /// *entering a room with no exit*. See `context/test-evidence/clients.md`. + /// A client must not enter a room with no exit. SavePrompt and ExportPrompt + /// now have download frames; see `context/test-evidence/clients.md`. #[test] fn a_prompt_this_client_cannot_commit_is_never_entered() { let state = in_mode(AppMode::Normal); - for (key, mode, subject) in [ + let mode = "command-mode"; + let KeyOutcome::Local(said) = resolve(&state, ev(KeyCode::Char(':'))) else { + panic!("{mode} must be refused *out loud*, not in silence"); + }; + let [Command::SetStatus(msg)] = &said[..] else { + panic!("{mode}: expected one status, got {said:?}"); + }; + assert!( + msg.contains("command line"), + "{mode}: the refusal must name its own subject, got {msg}" + ); + assert!( + !said.iter().any(|c| matches!(c, Command::ChangeMode(_))), + "{mode} must not be entered by a client that cannot commit it" + ); + + // SavePrompt and ExportPrompt ARE now enterable (improvise-2mqz). + for (key, mode_name) in [ ( KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL), "save-prompt", - "saving", ), ( KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), "export-prompt", - "exporting", ), - (ev(KeyCode::Char(':')), "command-mode", "command line"), ] { - let KeyOutcome::Local(said) = resolve(&state, key) else { - panic!("{mode} must be refused *out loud*, not in silence"); - }; - let [Command::SetStatus(msg)] = &said[..] else { - panic!("{mode}: expected one status, got {said:?}"); + let outcome = resolve(&state, key); + let KeyOutcome::Local(cmds) = outcome else { + panic!("{mode_name} should resolve locally now that it has a wire form"); }; assert!( - msg.contains(subject), - "{mode}: the refusal must name its own subject, got {msg}" - ); - assert!( - !said.iter().any(|c| matches!(c, Command::ChangeMode(_))), - "{mode} must not be entered by a client that cannot commit it" + cmds.iter().any(|c| matches!(c, Command::ChangeMode(_))), + "{mode_name} should be entered now that commit-save/commit-export have wire forms" ); } @@ -3747,6 +3827,7 @@ mod tests { let mut tiles = in_mode(AppMode::TileSelect); tiles.tiles.tiles = vec![crate::TileEntry { category: "Region".into(), + display: "Region".into(), axis: improvise_core::view::Axis::Row, kind: crate::CategoryKind::Regular, }]; diff --git a/crates/improvise-protocol/src/viewmodel.rs b/crates/improvise-protocol/src/viewmodel.rs index d216a444..a145f645 100644 --- a/crates/improvise-protocol/src/viewmodel.rs +++ b/crates/improvise-protocol/src/viewmodel.rs @@ -11,9 +11,10 @@ use crate::render::{CategoryTreeCache, ViewSummary}; /// so the entry list *is* what the panel draws. #[derive(Debug, Clone, PartialEq, Eq)] pub enum CatTreeEntry { - /// Category header row: name, item count, expanded? + /// Category header row: name (the key), what it shows, item count, expanded? Category { name: String, + display: String, item_count: usize, expanded: bool, axis: Axis, @@ -49,6 +50,7 @@ pub fn flatten_cat_tree( let is_expanded = expanded.contains(&row.name); entries.push(CatTreeEntry::Category { name: row.name.clone(), + display: row.display.clone(), item_count: row.items.len(), expanded: is_expanded, axis: row.axis, @@ -94,6 +96,7 @@ mod tests { .iter() .map(|(name, items)| CategoryRow { name: (*name).to_string(), + display: (*name).to_string(), items: items.iter().map(|i| i.to_string()).collect(), axis: Axis::None, }) @@ -112,6 +115,7 @@ mod tests { tree, vec![CatTreeEntry::Category { name: "Region".into(), + display: "Region".into(), item_count: 2, expanded: false, axis: Axis::None, diff --git a/crates/improvise-wasm-client/src/lib.rs b/crates/improvise-wasm-client/src/lib.rs index 88ae7c0e..60323736 100644 --- a/crates/improvise-wasm-client/src/lib.rs +++ b/crates/improvise-wasm-client/src/lib.rs @@ -8,7 +8,7 @@ pub mod socket; pub use improvise_protocol::{Click, ClientState, KeyOutcome, Request, resolve}; pub use keys::BrowserKey; pub use render::{RenderCatRow, RenderCell, RenderModel, RenderRow}; -pub use socket::EditTarget; +pub use socket::{DownloadKind, EditTarget, PendingDownload}; use improvise_protocol::{CellId, ClientFrame, RecordCell, Snapshot}; use wasm_bindgen::prelude::*; @@ -18,6 +18,8 @@ use wasm_bindgen::prelude::*; #[derive(Debug, Default)] pub struct Client { state: ClientState, + /// A download waiting for JavaScript to trigger (improvise-2mqz). + pending_download: Option, } /// An empty view slice and one empty cache per widget. Not exported: the @@ -92,6 +94,17 @@ impl Client { .map_err(|e| JsError::new(&e.to_string())) } + /// Take any pending download, returning it as JSON for JavaScript to handle. + /// Returns `None` if there is no pending download (improvise-2mqz). + #[wasm_bindgen] + pub fn take_pending_download(&mut self) -> Result, JsError> { + self.pending_download + .take() + .map(|d| serde_json::to_string(&d)) + .transpose() + .map_err(|e| JsError::new(&e.to_string())) + } + /// Everything the page draws, with every rendering decision already /// made. See [`render`]. #[wasm_bindgen] @@ -243,6 +256,12 @@ impl Client { // Nothing to fold: a request moves no state of this client's, and // what it moves on the host comes back as a projection. KeyOutcome::Request(request) => Some(request.frame()), + // Fold the commands locally (mode change), then send the request + // for download bytes (improvise-2mqz). + KeyOutcome::LocalThenRequest { cmds, request } => { + self.state.fold_outgoing(&cmds); + Some(request.frame()) + } KeyOutcome::Upstream(event) => Some(ClientFrame::key(event)), KeyOutcome::Ignored => None, } diff --git a/crates/improvise-wasm-client/src/render.rs b/crates/improvise-wasm-client/src/render.rs index 146d7035..e99c3c64 100644 --- a/crates/improvise-wasm-client/src/render.rs +++ b/crates/improvise-wasm-client/src/render.rs @@ -75,8 +75,8 @@ pub struct RenderModel { pub struct RenderMinibuffer { pub prompt: String, pub value: String, - /// Semantic, as `MinibufColor` is: the last translation to a CSS class is - /// the page's, exactly as `draw.rs::minibuf_fg` is the TUI's (gotcha 26). + /// A colour, not a role: the TUI now draws a face per text-entry mode, and + /// improvise-nn4s.4 moves the page onto the same faces (ADR 004). pub color: improvise_protocol::MinibufColor, } @@ -361,7 +361,7 @@ fn view_rows(state: &ClientState) -> Option> { .iter() .enumerate() .map(|(i, summary)| RenderViewRow { - name: summary.name.clone(), + name: summary.display.clone(), axes: improvise_protocol::viewmodel::view_axis_summary(summary), active: summary.name == state.view.active_view, selected: i == state.view.view_panel_cursor, @@ -396,7 +396,7 @@ fn tile_row(state: &ClientState) -> Vec { .iter() .enumerate() .map(|(i, tile)| RenderTile { - category: tile.category.clone(), + category: tile.display.clone(), axis: tile.axis.plain_label(), selected: selecting && i == state.view.tile_cat_idx, }) @@ -468,12 +468,13 @@ impl RenderCatRow { fn of(entry: &CatTreeEntry, selected: bool) -> Self { match entry { CatTreeEntry::Category { - name, + display, item_count, expanded, axis, + .. } => Self { - label: name.clone(), + label: display.clone(), is_category: true, axis: axis.initial().to_string(), item_count: Some(*item_count), @@ -591,6 +592,7 @@ mod tests { fn group(name: &str, collapsed: bool) -> FrameEntry { FrameEntry::Group(improvise_protocol::FrameGroup { level: 0, + depth: 0, name: name.into(), collapsed, }) @@ -805,11 +807,13 @@ mod tests { CategoryTreeUpdate::Categories(vec![ CategoryRow { name: "Region".into(), + display: "Region".into(), items: vec!["North".into(), "South".into()], axis: Axis::Row, }, CategoryRow { name: "Measure".into(), + display: "Measure".into(), items: vec!["Revenue".into()], axis: Axis::Column, }, @@ -842,6 +846,39 @@ mod tests { ); } + #[test] + fn the_panels_paint_displays_and_keep_keys_for_what_a_command_names() { + let mut client = init(); + let state = client.state_mut(); + state.fold(&Command::Project(Projection::CategoryTree( + CategoryTreeUpdate::Categories(vec![CategoryRow { + name: "Region".into(), + display: "Sales Area".into(), + items: vec!["North".into()], + axis: Axis::Row, + }]), + ))); + state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary { + name: "Default".into(), + display: "Overview".into(), + axes: vec![], + }]))); + state.fold(&Command::Project(Projection::TileBar(vec![TileEntry { + category: "Region".into(), + display: "Sales Area".into(), + axis: Axis::Row, + kind: improvise_protocol::CategoryKind::Regular, + }]))); + state.view.view_panel_open = true; + state.view.active_view = "Default".into(); + let model = client.model(); + assert_eq!(model.categories[0].label, "Sales Area"); + assert_eq!(model.tiles[0].category, "Sales Area"); + let views = model.views.expect("the view panel is open"); + assert_eq!(views[0].name, "Overview"); + assert!(views[0].active, "the active view is still found by its key"); + } + fn client_with_panels() -> Client { let mut client = init(); client @@ -849,10 +886,12 @@ mod tests { .fold(&Command::Project(Projection::ViewList(vec![ ViewSummary { name: "Default".into(), + display: "Default".into(), axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)], }, ViewSummary { name: "Plan".into(), + display: "Plan".into(), axes: vec![], }, ]))); @@ -866,11 +905,13 @@ mod tests { .fold(&Command::Project(Projection::TileBar(vec![ TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Row, kind: improvise_protocol::CategoryKind::Regular, }, TileEntry { category: "Product".into(), + display: "Product".into(), axis: Axis::None, kind: improvise_protocol::CategoryKind::Regular, }, @@ -1191,11 +1232,13 @@ mod tests { .fold(&Command::Project(Projection::TileBar(vec![ TileEntry { category: "Quarter".into(), + display: "Quarter".into(), axis: Axis::Page, kind: improvise_protocol::CategoryKind::Regular, }, TileEntry { category: "Region".into(), + display: "Region".into(), axis: Axis::Column, kind: improvise_protocol::CategoryKind::Regular, }, diff --git a/crates/improvise-wasm-client/src/socket.rs b/crates/improvise-wasm-client/src/socket.rs index 611d8f19..8070d307 100644 --- a/crates/improvise-wasm-client/src/socket.rs +++ b/crates/improvise-wasm-client/src/socket.rs @@ -8,6 +8,24 @@ use improvise_protocol::{ }; use serde::{Deserialize, Serialize}; +/// A `DocumentBytes` or `CsvBytes` download held until JavaScript retrieves it +/// through `Client::take_pending_download` (improvise-2mqz). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingDownload { + pub filename: String, + pub bytes: Vec, + pub kind: DownloadKind, +} + +/// What kind of content is being downloaded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DownloadKind { + /// The document in `.improv` format. + Document, + /// The active view as CSV. + Csv, +} + /// Everything an editor captures when it opens, held opaquely by the page. /// A struct so a field such as the seed can join it — `context/code/clients.md`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -72,6 +90,7 @@ fn rejection_message(rejection: &Rejection) -> String { "this host has nowhere to write the document — it was not saved".into() } Rejection::SaveFailed => "the document could not be written".into(), + Rejection::ExportFailed => "the view could not be exported".into(), } } @@ -82,7 +101,8 @@ impl Client { ClientFrame::Viewport { rows, cols } } - /// Fold one server frame, in arrival order. + /// Fold one server frame in order. Downloads leave the revision unchanged + /// and wait for JavaScript; other frames return the new revision. pub fn fold_frame(&mut self, frame: ServerFrame) -> u64 { match frame { ServerFrame::Snapshot(snapshot) => self.bootstrap(*snapshot), @@ -100,6 +120,22 @@ impl Client { state.notify(message); } ServerFrame::Accepted => self.state_mut().accepted(), + // Download frames carry bytes the browser needs to trigger a download. + // Store them for JavaScript to retrieve via `take_pending_download`. + ServerFrame::DocumentBytes { filename, bytes } => { + self.pending_download = Some(PendingDownload { + filename, + bytes, + kind: DownloadKind::Document, + }); + } + ServerFrame::CsvBytes { filename, bytes } => { + self.pending_download = Some(PendingDownload { + filename, + bytes, + kind: DownloadKind::Csv, + }); + } } self.revision() } @@ -223,7 +259,7 @@ mod tests { } /// A rejection that refused an **edit** must say nothing was applied; the - /// two `Save` answers refused none. Evidence: clients.md (improvise-8uj8). + /// save/export answers refused none. Evidence: clients.md (improvise-8uj8). #[test] fn every_rejection_message_says_what_it_refused() { let hash = surrogate("fe4c8e5e9cbd578e0d388f4b2b5a7879"); @@ -238,9 +274,11 @@ mod tests { // Not "not applied": nothing was asked to apply. It must still // say the thing it *did* refuse did not happen. false => assert!( - msg.contains("not saved") || msg.contains("could not be written"), - "{rejection:?} refused a save but is worded as {msg:?}, \ - which does not say the document was not written" + msg.contains("not saved") + || msg.contains("could not be written") + || msg.contains("could not be exported"), + "{rejection:?} refused a save/export but is worded as {msg:?}, \ + which does not say the operation failed" ), } } diff --git a/crates/improvise-wasm-client/tests/browser_round_trip.rs b/crates/improvise-wasm-client/tests/browser_round_trip.rs index a5a9947d..0102a811 100644 --- a/crates/improvise-wasm-client/tests/browser_round_trip.rs +++ b/crates/improvise-wasm-client/tests/browser_round_trip.rs @@ -189,6 +189,7 @@ fn a_grouped_row_axis_does_not_shift_the_write() { // two rows — a leading header is dropped by `AxisFrame::body_from`. wb.add_item("Region", "West").unwrap(); wb.add_item_in_group("Region", "East", "Coastal").unwrap(); + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); @@ -620,6 +621,103 @@ fn ctrl_s_in_the_browser_writes_the_hosts_document() { ); } +/// Codex on PR #329: committing a download closed the browser's prompt but +/// left the host mirror in it, so a later projection could restore stale state. +#[test] +fn a_download_commit_keeps_the_host_session_mirror_in_lockstep() { + use improvise_command::ui::reduce::caught_up; + use improvise_protocol::{AppMode, ModelCommand, ServerFrame}; + + let mut host = host(); + let mut client = Client::new(); + let (id, snapshot) = host.connect(10, 10); + deliver(&mut client, &[snapshot]); + + let open = client + .key(&BrowserKey { + key: "w".into(), + ctrl: true, + alt: false, + shift: false, + meta: false, + }) + .expect("Ctrl-W opens the save prompt"); + let opened = host.handle(id, open); + deliver(&mut client, &opened); + for key in ["d", "o", "c", ".", "i", "m", "p", "r", "o", "v"] { + press(&mut host, id, &mut client, key); + } + assert!(matches!( + client.state().view.mode, + AppMode::SavePrompt { .. } + )); + assert_eq!( + host.sessions().get(id).expect("attached").view().mode, + client.state().view.mode, + "setup: both ends must be in the prompt before Enter" + ); + + let commit = client + .key(&BrowserKey::new("Enter")) + .expect("Enter composes a download request"); + assert!( + matches!( + &commit, + ClientFrame::DownloadDocument { filename } if filename == "doc.improv" + ), + "the USER RULING is a bytes request, got {commit:?}" + ); + let answer = host.handle(id, commit); + assert!( + answer + .iter() + .any(|frame| matches!(frame.msg, ServerFrame::DocumentBytes { .. })), + "the request must still return bytes: {answer:?}" + ); + deliver(&mut client, &answer); + + let mirrored = host.sessions().get(id).expect("attached").view(); + assert_eq!( + mirrored.mode, + AppMode::Normal, + "the host must close the prompt in its session mirror" + ); + assert_eq!( + mirrored.buffers.get("save").map(String::as_str), + Some(""), + "the host must clear the same minibuffer as the client" + ); + + // Force a subsequent document projection from another session, then fold + // what it addresses to the requester. + let (bystander, _) = host.connect(10, 10); + let write = caught_up( + host.app(), + ModelCommand::SetCell { + view: "Default".into(), + cell: client + .record_cell_at(0, 0) + .expect("North/Revenue is stored"), + value: CellValue::Number(77.0), + }, + ); + let projected = host.handle(bystander, ClientFrame::command(write)); + let for_requester: Vec = projected + .into_iter() + .filter(|frame| frame.to == id) + .collect(); + assert!( + !for_requester.is_empty(), + "the regression must process a later projection" + ); + deliver(&mut client, &for_requester); + assert_eq!( + &client.state().view, + host.sessions().get(id).expect("attached").view(), + "the later projection must leave the browser and host mirror in lockstep" + ); +} + /// A refused frame must leave this client where the **host** says it is, and /// the host says so in its own mirror. See `context/test-evidence/clients.md`. #[test] diff --git a/crates/improvise-wasm-client/tests/lockstep.rs b/crates/improvise-wasm-client/tests/lockstep.rs index ebfa8d64..acc90118 100644 --- a/crates/improvise-wasm-client/tests/lockstep.rs +++ b/crates/improvise-wasm-client/tests/lockstep.rs @@ -1595,6 +1595,9 @@ fn deliver( } improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), improvise_protocol::ServerFrame::Accepted => client.accepted(), + // Download responses are not processed in this test context (improvise-2mqz). + improvise_protocol::ServerFrame::DocumentBytes { .. } + | improvise_protocol::ServerFrame::CsvBytes { .. } => {} } } } @@ -2258,6 +2261,7 @@ fn fold_host() -> App { for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("May", "Q2")] { wb.add_item_in_group("Month", month, quarter).unwrap(); } + wb.active_view_mut().set_expand_depth("Month", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.add_item("_Measure", "Cost").unwrap(); for month in ["Jan", "Feb", "Apr", "May"] { @@ -2272,7 +2276,7 @@ fn fold_host() -> App { } wb.active_view_mut().set_axis("Month", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); - wb.active_view_mut().toggle_group_collapse("Month", "Q2"); + wb.active_view_mut().toggle_group_collapse("Month", &["Q2"]); let mut app = App::new(wb, None); app.rebuild_layout(); assert_eq!(app.layout.row_label(2), "Q2", "setup: the fold is row 2"); @@ -2469,7 +2473,7 @@ fn advancing_the_editor_past_the_window_settles_where_the_tui_does() { let expanded = || { let mut app = fold_host(); let view = app.model_state.workbook.active_view_mut(); - view.toggle_group_collapse("Month", "Q2"); + view.toggle_group_collapse("Month", &["Q2"]); app.rebuild_layout(); app }; @@ -3543,20 +3547,22 @@ fn nested_groups_host(coast_collapsed: bool, kit_collapsed: bool) -> App { for item in ["East", "West"] { wb.add_item_in_group("Region", item, "Coast").unwrap(); } + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("Product", "Widget").unwrap(); for item in ["Gadget", "Gizmo"] { wb.add_item_in_group("Product", item, "Kit").unwrap(); } + wb.active_view_mut().set_expand_depth("Product", 1); wb.add_item("_Measure", "Revenue").unwrap(); let view = wb.active_view_mut(); view.set_axis("Region", Axis::Row); view.set_axis("Product", Axis::Row); view.set_axis("_Measure", Axis::Column); if coast_collapsed { - view.toggle_group_collapse("Region", "Coast"); + view.toggle_group_collapse("Region", &["Coast"]); } if kit_collapsed { - view.toggle_group_collapse("Product", "Kit"); + view.toggle_group_collapse("Product", &["Kit"]); } let mut app = App::new(wb, None); app.term_height = 12 + 8; @@ -3614,11 +3620,12 @@ fn folded_host() -> App { for item in ["East", "West"] { wb.add_item_in_group("Region", item, "Coast").unwrap(); } + wb.active_view_mut().set_expand_depth("Region", 1); wb.add_item("_Measure", "Revenue").unwrap(); wb.active_view_mut().set_axis("Region", Axis::Row); wb.active_view_mut().set_axis("_Measure", Axis::Column); wb.active_view_mut() - .toggle_group_collapse("Region", "Coast"); + .toggle_group_collapse("Region", &["Coast"]); let mut app = App::new(wb, None); app.term_height = 4 + 8; app.rebuild_layout(); @@ -3727,6 +3734,9 @@ fn heard( } improvise_protocol::ServerFrame::Rejected(r) => client.refused(r), improvise_protocol::ServerFrame::Accepted => client.accepted(), + // Download responses are not processed in this test context (improvise-2mqz). + improvise_protocol::ServerFrame::DocumentBytes { .. } + | improvise_protocol::ServerFrame::CsvBytes { .. } => {} } } } diff --git a/crates/improvise-worker-server/src/worker.rs b/crates/improvise-worker-server/src/worker.rs index cafd0607..2f64889e 100644 --- a/crates/improvise-worker-server/src/worker.rs +++ b/crates/improvise-worker-server/src/worker.rs @@ -256,6 +256,8 @@ mod tests { } } ServerFrame::Rejected(_) | ServerFrame::Accepted => {} + // Download responses are not processed in this test context (improvise-2mqz). + ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {} } } grid.clone() diff --git a/crates/improvise-worker-server/tests/post_message.rs b/crates/improvise-worker-server/tests/post_message.rs index db6832d6..288570b4 100644 --- a/crates/improvise-worker-server/tests/post_message.rs +++ b/crates/improvise-worker-server/tests/post_message.rs @@ -113,6 +113,8 @@ impl MainThread { } } ServerFrame::Rejected(_) | ServerFrame::Accepted => {} + // Download responses are not processed in this test context (improvise-2mqz). + ServerFrame::DocumentBytes { .. } | ServerFrame::CsvBytes { .. } => {} } } @@ -351,11 +353,17 @@ fn the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable() { } // The third column is `Profit`, which a formula owns: the page must not // offer it, so it carries no surrogate to offer (improvise-ir7l). - for row in 0..main.grid.row_frame.len() { + for (row, cells) in main + .grid + .cells + .iter() + .enumerate() + .take(main.grid.row_frame.len()) + { assert!( - matches!(main.grid.cells[row][2].id, Some(CellId::Derived { .. })), + matches!(cells[2].id, Some(CellId::Derived { .. })), "row {row}'s Profit cell must be Derived, got {:?}", - main.grid.cells[row][2].id + cells[2].id ); } diff --git a/flake.lock b/flake.lock index 32d9649a..f0a00207 100644 --- a/flake.lock +++ b/flake.lock @@ -297,11 +297,11 @@ }, "nixpkgs_4": { "locked": { - "lastModified": 1790046670, - "narHash": "sha256-MYiI+CzL0tuWgRPjGsKCDHqYs2T3OzMlMQWOYWG0qso=", + "lastModified": 1790185690, + "narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "6774f7bc253789b113a4f39285dc0fa100abeacc", + "rev": "4975466d324710c576dc11ad614684e6bd8cad8e", "type": "github" }, "original": { @@ -368,11 +368,11 @@ "nixpkgs": "nixpkgs_5" }, "locked": { - "lastModified": 1790062494, - "narHash": "sha256-Mds1vuoczq1fRm1rf+Iar8IKDBHNjOMo2JqqPWJUlng=", + "lastModified": 1790234952, + "narHash": "sha256-tjQa1vDoHJir4lasZTakz/xgmsfB6SRjeQqaIbefFOk=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "b88764907c1b2de6c0e206d8d9d9634d91c81334", + "rev": "ed34466c59b766481248959a21a6e46cf0c60468", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 47dfc078..278b5d65 100644 --- a/flake.nix +++ b/flake.nix @@ -47,13 +47,13 @@ wasmBindgenCli = pkgs.buildWasmBindgenCli rec { src = pkgs.fetchCrate { pname = "wasm-bindgen-cli"; - version = "0.2.128"; - hash = "sha256-a7lcXJnnZkYReja+iUO7NqqrWyv3toxnUgQb8s4IS5s="; + version = "0.2.129"; + hash = "sha256-pcecKQd7E8Opw6bkFoE569epUi7gh5qpQF1e5PJY6V8="; }; cargoDeps = pkgs.rustPlatform.fetchCargoVendor { inherit src; inherit (src) pname version; - hash = "sha256-R1Tas33Ursy8kqsxguAkG0ZhNed2n5uFTAhw1l2qlLY="; + hash = "sha256-vmUrWVU7kPJJxO5qIVeAkwQyWDELO1Z4Z5gitz2kco8="; }; }; @@ -85,6 +85,17 @@ pkgs.jq ]; + # scripts/repo-stats runs code-maat (improvise-1xfb.5.1). The JRE is about + # 0.6 GB, so it is kept out of ciInputs; only `nix-build` takes it. + + codeMaatJar = pkgs.fetchurl { + url = "https://github.com/adamtornhill/code-maat/releases/download/v1.0.4/code-maat-1.0.4-standalone.jar"; + hash = "sha256-QoeuIDSQGERFD3aVR7xEl6DaGm0cf6b63IWHBeZ0O18="; + }; + codeMaat = pkgs.writeShellScriptBin "code-maat" '' + exec ${pkgs.jre_headless}/bin/java -jar ${codeMaatJar} "$@" + ''; + # Playwright's downloaded browsers are generic-Linux builds that cannot # start on NixOS, so nixpkgs' build is used. harness/package.json pins # @playwright/test to this playwright-driver's version: they must match. @@ -114,6 +125,7 @@ pkgs.global pkgs.universal-ctags beads.packages.${system}.default + codeMaat # Demo recording and release tooling pkgs.asciinema @@ -129,6 +141,13 @@ // { improvise = cargoNix.workspaceMembers.improvise.build; default = self.packages.${system}.improvise; + code-maat = codeMaat; + # What the repo-stats pipeline runs besides the ci shell: one + # installable, because check-ci-graph reads `nix shell --command`. + repo-stats-tools = pkgs.symlinkJoin { + name = "repo-stats-tools"; + paths = [self.packages.${system}.improvise codeMaat]; + }; }; # `nix flake check` builds every CI build: crane's checks plus the diff --git a/harness/fixtures/grouped.improv b/harness/fixtures/grouped.improv index 164bce39..8e8d6005 100644 --- a/harness/fixtures/grouped.improv +++ b/harness/fixtures/grouped.improv @@ -11,6 +11,8 @@ _Measure: page, Revenue _Index: none _Dim: none collapsed: Region/Upland +expanded(1): Month +expanded(1): Region format: ,.0 ## Category: Region diff --git a/harness/fixtures/tall.improv b/harness/fixtures/tall.improv index 5daa32d2..c16fc14d 100644 --- a/harness/fixtures/tall.improv +++ b/harness/fixtures/tall.improv @@ -8,6 +8,7 @@ _Dim: none _Measure: page Region: row format: ,.0 +expanded(1): Region ## View: Tall _Index: none @@ -15,6 +16,7 @@ _Dim: none _Measure: column Region: row format: ,.0 +expanded(1): Region ## Category: _Measure - Revenue, Cost diff --git a/harness/opfs-contract/Cargo.lock b/harness/opfs-contract/Cargo.lock index 579594c6..67bee125 100644 --- a/harness/opfs-contract/Cargo.lock +++ b/harness/opfs-contract/Cargo.lock @@ -187,6 +187,7 @@ dependencies = [ "indexmap", "serde", "siphasher", + "unicase", ] [[package]] @@ -238,9 +239,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -471,12 +472,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -485,9 +501,9 @@ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -498,19 +514,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -518,9 +535,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -531,18 +548,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/prolog-sessions/kb.pl b/prolog-sessions/kb.pl index b1d8fcd7..62d656f5 100644 --- a/prolog-sessions/kb.pl +++ b/prolog-sessions/kb.pl @@ -141,6 +141,10 @@ invariant(fmt_gate_exit_two_means_the_gate_did_not_run,'scripts/fmt-workspaces.s invariant(a_frame_group_level_names_a_category,'An AxisFrame deserialises through UncheckedAxisFrame and REFUSES a FrameGroup or GroupEnd whose level is >= cats.len(). A level naming no category is a malformed frame from the wire, never something to clamp, so the check is at the one door every frame arrives through. context/code/protocol.md section 3b.'). invariant(a_grid_evaluates_only_through_its_own_evaluator,'GridLayout::evaluator builds a GridEval whose fold memo that layout built from its own none_cats and hidden items, and a grid evaluates only through it, so no other grid memo can reach its cells or totals (improvise-opbo, where a memo built by another layout silently lent its aggregation and hidden set). prune_empty shares one GridEval across its whole emptiness test rather than a fresh memo per cell (improvise-metf).'). invariant(an_unprojected_cell_is_unknown,'Every client read of the grid cache goes through projected_at, which subtracts the frame origin (AxisFrame::first) before indexing. A position outside the window the cache holds is UNKNOWN, never the value at the same cache index, and a cell op on it waits. Reading the cache through offsets the client had scrolled ahead to made a pipelined edit write a different cell than the TUI would (improvise-7mro).'). +invariant(default_duplicate_policy_is_refuse,'ImportPipeline::on_duplicate defaults to DuplicatePolicy::Refuse, so any path that never asked the question, the headless CLI included (--on-duplicate defaults to refuse), errors naming the collisions rather than summing. On ResolveDuplicates, Refuse blocks advance with a message; the build under Refuse returns an error when collisions exist (improvise-cgqt).'). +invariant(a_collision_is_a_cell_written_twice,'A collision is a cell -- a coordinate tuple AND a measure -- written by two or more records, never merely a shared tuple: records with disjoint measures on one tuple write different cells and collide with nothing, and only the records that share a cell are listed and numbered (improvise-9bit). Plan::place is the one placement pass, used by both collisions() and build_model_with_warnings, so the question asked and the model built cannot disagree.'). +invariant(wizard_draws_are_clipped_to_the_panel,'Every import-wizard write goes through Panel in src/ui/import_wizard_ui.rs, which clips to the panel rows and width, so no step can draw past the buffer; text is measured in cells by ui::text::truncate and wrap, never in bytes (review B1/B2 and a Codex P2 on PR #304).'). +invariant(a_window_over_a_held_summary_equals_grid_body,'grid_window over the GridSummary of the same layout returns what the one-walk grid_body returns for that window, so the browser window and the TUI whole grid cannot disagree on a width, a format or a total. Pinned by a_window_over_a_held_summary_equals_the_one_walk_grid_body in improvise-command ui/viewport.rs; it holds only where the summary is current, which is why App::grid_geometry is dropped with the layout.'). doctrine(bug_first,'write a failing test demonstrating the bug before fixing it'). doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e'). doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).'). @@ -222,7 +226,6 @@ doctrine(a_destructive_default_warns_after_the_write,'bd update --notes REPLACES doctrine(parallel_actors_need_distinct_names,'bd\'s --actor defaults to $BEADS_ACTOR, then git user.name, then $USER, so a wave of agents sharing a machine shares an actor and the claim guards cannot tell them apart -- two agents claim one bead and neither is told. Exit 13 means another actor won the race; that is the mechanism working, not a failure to retry around. User instruction, 2026-09-20.'). doctrine(a_stale_gotcha_asserts_the_negation,'repo-map gotcha 17 said float equality was \"currently mixed\" and named the div-by-zero guard as the exact-comparison half, long after both had been unified on FLOAT_EQ_EPSILON. A doc that goes stale does not merely omit -- it tells the reader not to assume the thing that is now true. This is why the orchestrator doc pass VERIFIES each claim against the tree rather than applying a queued edit.'). doctrine(an_advisory_gate_is_not_a_gate,'scripts/check-comment-runs.py was written, fixed seven times, and quoted by seven agents as their definition of done while being invoked by NO workflow. A rule enforced only by the agents who agreed to it is a convention. The gate landed last, deliberately, because a gate that goes green before the work is done is the same class of lie the checker itself carried.'). -doctrine(a_review_names_the_sha_it_reviewed,'A review of a superseded commit is worse than no review: it reads as a pass. So the reviewer names the exact SHA, the author does not rewrite history while a review is in flight and announces any push, and a re-review is of the delta. This matters more now that adversarial agents are the ONLY gate -- Copilot\'s quota is exhausted for the rest of 2026-09 and its request is a SILENT no-op (the mutation returns success and registers no reviewer), which is itself an instance of doctrine(an_advisory_gate_is_not_a_gate).'). doctrine(review_is_requested_not_inherited,'No PR is reviewed because it exists. Requesting review is a step in the author\'s own definition of done, ranked beside \"tests pass\", not a courtesy notification afterwards. The failure mode it prevents is the quiet one: an agent finishes, opens a PR, reports success and waits on a review that was never going to come -- indistinguishable, from the outside, from a PR waiting its turn. Note the shape repeats: an ABSENT gate and a SILENTLY no-op request both read as \"in progress\", which is an_advisory_gate_is_not_a_gate seen from the requester\'s side. The user had turned Copilot AUTO-review off, which is the actual explanation for PRs #37/#41/#42 merging unreviewed; the orchestrator had attributed that to quota, and the two are independent facts.'). doctrine(construction_is_not_loading,'A lazily-loading embedded player proves nothing by existing. asciinema-player with autoPlay:false does not FETCH its cast until the viewer clicks, so a page whose every cast path 404s constructs the full set of players and throws no page error. The agent\'s own check passed on a page broken exactly that way (./casts/ -> ./cats/); pressing play returned 0 rendered lines and four 404s. Generalises: any check on a lazily-initialised embed must trigger the load it is about.'). doctrine(a_deployed_path_is_a_grep_across_the_tests,'Moving where something is served is not a build-script change, it is a grep across the TEST surface. The landing-page inversion (user ruling on improvise-kh8) cost one line in web/assemble.sh and six navigations in harness/tests/shell.spec.js, a CI-gating job the agent did not own. Nothing about \"serve the landing page at the root\" says \"a browser test job goes red\". In this repo the harness is the surface LEAST likely to be in the mental model, and for a structural reason: it is not a workspace member and cargo test never touches it, so every habit the repo has built is blind to it. Cross-reference: the harness serves the DEPLOYED arrangement on purpose (improvise-vvl3), which is what makes it trustworthy and also what makes a layout change a test change.'). @@ -253,7 +256,6 @@ doctrine(a_message_assertion_is_a_uniqueness_claim_about_the_whole_vocabulary,'A doctrine(run_the_gate_ci_runs,'A local gate differing from CI in its flags or its output filter can only fail in directions the defect is not. On PR #71 the author ran clippy WITHOUT -D warnings and grepped for a lint name in brackets, which cannot match the bare \"warning:\" unused_imports emits, so the check printed zero and read clean while CI went red. Worse, the repo own code-review skill specified the same flagless invocation, so it was the INSTRUCTION rather than one person shell mistake -- a reviewer following it would report clean and be wrong. Invoke the gate exactly as the workflow does, flags and all.'). doctrine(an_observation_correct_about_what_changed_can_be_wrong_about_when_it_is_read,'improvise-pdvm/75ld replaced a DECLARED classification with an OBSERVED one, which is right. The cost landed on WHEN the observation is read: comparing document_config once AROUND the batch is correct about what changed and wrong about ordering, because Save clears dirty INSIDE the loop, so [SetNumberFormat, Save] saved and then re-raised the flag. The fix is position, not suppression -- the comparison moved into the loop, using |= so it only ever sets. The asymmetry that decides it: nothing ever CLEARS layout_version, so an end-of-batch comparison is right there, while dirty has an effect that unsets it mid-batch. The discriminating probes rule out BOTH wrong fixes: [SetNumberFormat] alone must still be dirty, or LayoutGeometry has been demoted and the document never saves; and [Save, SetNumberFormat] must be dirty, or the fix is a blanket never-dirty-after-save.'). doctrine(a_siblings_unmerged_pr_is_invisible_to_bd_search,'AGENTS.md requires a bd search before filing. In a parallel wave that is not sufficient: a fix living in a sibling agent UNMERGED PR is in no tracker state at all, so the search correctly returns nothing and the duplicate is filed anyway. Observed 2026-09-20 -- the tooling agent filed improvise-v5pc to restore a deleted help assertion while the harness agent was landing exactly that in PR #72. Neither agent erred. Check OPEN PRS as well as the tracker, and have the orchestrator reconcile filings against merged work at wave end rather than trusting each agent to have seen the others.'). -doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Two rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main that is going to be resynced. And after any reset or force-move of a branch holding knowledge, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.'). doctrine(a_checker_that_reads_headings_cannot_see_a_contradicting_body,'check-evidence validates that a heading names a test that exists. It says nothing about the PROSE under the heading, which is what a human actually reads. Demonstrated on this repo 2026-09-20: renaming a retired entry to the test that had been restored made the checker green while the body three paragraphs down still said \"The test no longer exists\" and \"nothing replaced the assertion\" -- one entry making mutually exclusive claims, and the gate could not see it because the gate was never about that. Found by review, not by the checker. The general form is that a mechanical check licenses exactly the claim it makes and no neighbouring one, so a rename is a read of the whole entry rather than an edit of its first line -- a_fact_stated_twice_is_changed_by_grep applied WITHIN a document rather than across them.'). doctrine(a_cost_note_names_the_half_the_author_was_thinking_about,'Twice now a reviewer has been asked to check a claim of the form \"X is now O(n) where it was O(1)\", and both times the interesting answer was not whether the claim was true but WHICH SIDE OF IT A READER WOULD ACT ON. On PR #76 the author wrote that the cost is \"O(cells) per CellData OR LayoutGeometry effect, where it was per LayoutGeometry effect\" -- which reads as though CellData is the newly burdened path. Measured at 18,000 cells it is the reverse: CellData was ALREADY O(cells) through record_fingerprints, so widening it is a constant factor (+27%), while LayoutGeometry went from O(config) to O(cells + config) and got 33x slower, 78us to 2.63ms, on the held-key-repeat operations. Not dishonest -- the author was thinking about WHICH EFFECTS NEWLY QUALIFY rather than WHICH PATH NEWLY CHANGED COMPLEXITY CLASS, and those are different questions with different answers. A cost note is worth a measurement precisely when the author can say confidently which half is worse, because that confidence is what stops anyone measuring.'). doctrine(name_the_test_that_fails_when_only_this_edit_is_reverted,'A repair is usually N edits, and the failure mode is that N-1 are guarded while the last is explained in a COMMENT instead. Three independent reviews in one wave found the same shape: improvise-mai9 (the helper was tested, the caller that actually rendered was not), improvise-0x9u (preventDefault and the close/dismiss split were both tested, the drawn-vs-seed restore was not, and reverting it shows 18400 where 18,400 belongs on the shipped demo), improvise-o31u (the phone arm was measured, the desktop arm was justified by a mechanism measurement contradicts). The unguarded edit is reliably the one the author understood BEST -- which is why it carries the most careful comment, and why it felt covered: the comment is doing the work the test should, and it reads as evidence because it is the most thoughtful thing on the screen. The check is per-edit, not per-fix: for each distinct edit, name the test that fails when ONLY that edit is reverted. A line with a comment and no answer is the line to inject.'). @@ -388,6 +390,9 @@ doctrine(a_pipe_discards_the_exit_code_you_meant_to_read,'FOURTH occurrence in o doctrine(a_dev_shell_is_a_third_environment,'.envrc puts nix bash 5.3 on PATH -- the RIGHT INTERPRETER, CI semantics -- and pkgs.mkShell ALSO exports about thirty generically-named lowercase variables the runner does not have: name, out, outputs, system, shell, builder, stdenv, phases, buildInputs, doCheck and more. A script reading one by accident under set -u is SILENT IN THE DEV SHELL AND FATAL IN CI. improvise-f9qf: pr-reviews-test.sh passed 19/19 under macOS bash 3.2 AND 19/19 under the dev shell bash 5.3, while failing 2/19 in CI -- two different reasons for green, one of them the correct interpreter, and unsetting \`name\` alone reproduced CI 2/17 exactly. Note nix shell does NOT export name and nix develop DOES (verified with env -u name as the control), so the dev shell is the one nix entry point that masks this class. The orchestrator own verification of this was itself confounded by inheriting name from a direnv-loaded shell -- it answered \"does name end up set\" where the question is \"does nix shell set it\". CHANGED 2026-09-24 (#287, #297): every CI step now runs inside nix develop .#ci, itself a pkgs.mkShell, so CI exports those variables too and the dev shell no longer masks a CI failure of this shape. The hazard moved rather than vanished: the environment that differs now is a BARE local shell with no direnv, and checkers.sh still scrubs to an allowlist so a suite cannot come to depend on one of them either way.'). doctrine(checkout_ours_or_theirs_drops_the_other_sides_clean_changes,'git checkout --ours or --theirs on a conflicted file takes the WHOLE file from one side, so it silently drops every non-conflicting change the other side made to that file. #245 resolved a ci.yml conflict with #228 by --theirs and lost its own check-harness-lock --allow-network step while its merge message said the step was unchanged (improvise-dndu). No gate saw it: check-ci-graph.py counts suites, not that step, and a message cannot be checked at all. Resolve by hand, then diff the merge against the merge base and against EACH parent -- the question is not whether git combined the texts but whether the result holds both sides.'). doctrine(a_clean_merge_of_one_test_file_can_still_fail_together,'Two branches can each pass CI, merge with no conflict, and fail together. #287 found that on the NixOS runners /usr/bin:/bin holds only env and sh, and gave section 5 of checkers-test.sh a hermetic utilbin PATH; #237, cut earlier, added section 5b still using PATH=...:/usr/bin:/bin. The merge was textually clean and 5b broke every PR after it (#296). A fix to an IDIOM -- a hermetic PATH, a scrubbed environment, a helper every case must call -- is owed by every case in the file, including cases that exist only on another open branch. After a merge, grep the merged file for the old idiom, and run the gates on the merged tree rather than on each side.'). +doctrine(history_is_reconciled_only_by_merge,'The user, 2026-09-25: the merge structure of git history is a causal record (improvise-1xfb.1). A merge keeps that record, and a rewrite replaces it with a story. So: (1) the default is a NEW Conventional Commit -- As a general rule, you shuold just add a new conventional commit. Small adjustments can ammend HEAD, but when in doubt don\'t [sic] (improvise-1xfb.2); (2) amend only HEAD, only for a small adjustment, and only while it is unpushed; (3) never reorder, fold or squash commits, pushed or not, and never move them onto another base -- no git rebase and no git reset --soft gh/main; (4) nothing that would need a force push, and no force push; (5) a newer main is taken by re-pinning gh/main and running git merge on that SHA; (6) git cherry-pick -x only for commits that will never reach main as they are -- an abandoned or rebuilt PR, or commits only on a mirror or a local main -- since a cherry-pick drops the causal link. The rewrite is also where the damage came from: twice a git reset --soft gh/main computed a squash against a ref another session had moved and staged a silent revert of a merged PR (#196, improvise-diho; #303, caught by the numstat check). The rule text is .agents/skills/wave/agent-rules.md rule 8.'). +doctrine(a_review_names_the_sha_it_reviewed,'A review of a superseded commit is worse than no review: it reads as a pass. So the reviewer names the exact SHA, the author announces every push, and a re-review is of the delta. The author never rewrites history, in review or out of it: new commits and merges only, an amend only to an unpushed HEAD, and nothing that needs a force push (doctrine(history_is_reconciled_only_by_merge); agent-rules rule 8; improvise-at5k). Each fix is therefore a new commit on top of the reviewed SHA, and the delta is exactly what the re-review reads. This matters more now that adversarial agents are the gate a merge waits on -- Copilot\'s request was a SILENT no-op for much of 2026-09 (the mutation returned success and registered no reviewer), which is itself an instance of doctrine(an_advisory_gate_is_not_a_gate).'). +doctrine(a_hard_reset_discards_knowledge_written_to_the_working_branch,'The orchestrator wrote doctrines to kb.pl on local main, pushed each as a branch, then ran git reset --hard gh/main to resync -- twice -- and silently discarded six clauses that existed only in the unmerged local commits. Nothing failed: kb_consistent passed on the reduced file, the counts simply read lower, and the loss was found only by grepping for clause names remembered from the session. Three rules follow. Write a fact ONCE, on the branch that will carry it to main, not on a local main. Resync local main by git merge --ff-only gh/main, never by a reset: a reset or force-move that drops commits is forbidden under doctrine(history_is_reconciled_only_by_merge) (improvise-at5k), and the ff-only merge refuses exactly where the reset would have discarded something. And if a branch holding knowledge has lost commits anyway, re-count the predicates and diff clause NAMES against what you believe you wrote -- a total that merely looks plausible is the same non-evidence a passing test count is.'). work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result; three tests in ui/effect.rs.'). work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.'). work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.'). @@ -439,6 +444,17 @@ work_done('improvise-nxah',bugfix,'PR #136, merge eefd992. The client rolls back work_done('improvise-tg27',bugfix,'PR #138, merge 327a76b. header flex-wrap over a breakpoint; main min-width:0; panels capped at 40vw; harness/tests/narrow.spec.js asserts documentElement.scrollWidth <= clientWidth at 360/520/768, so the page own overflow leaves gotcha 48 list of what the harness cannot see. Fault injected by restoring style.css to 22ab58f: 6 of 9 assertions failed, and the two that passed are the two that should. Merge with improvise-nxah verified STRUCTURALLY -- 198 insertions and ZERO deletions across context/, with evidence entries rising 812 to 818 as the independent witness. Found not predicted: the keyboard already reached every clipped column, so what was broken was a FALSE scroll affordance beside a working one.'). work_done('improvise-k844',bugfix,'PR #139, merge 1ae2d2e. importance_ranking sums and divides in f64, narrowing only the bounded share. The injected rival is THE TICKET OWN TITLE -- reassociating to 100.0*(imp/total) in f32 passes the lone-importance and sums-to-100 tests and fails the twin and the guard, 58 passed 2 failed, converting NaN% into a serene 0% for every feature. Scoring bit patterns byte-identical, which is the evidence the fix is confined. context/code/gp.md section 7 corrected in place: it had deferred this bug on a cost that does not exist.'). work_done('improvise-f9qf',tooling,'PR #137, merge a9cad15. scripts/checkers.sh plus scripts/checkers-test.sh (12 cases, each INJECTING rather than asserting). The proof: restoring the multi-assignment local gives 22 passed under the old local gate, 22 passed under direnv, and 2 passed / 20 failed / exit 1 under checkers.sh. The second undetected split is closed -- the four #!/bin/sh and one #!/usr/bin/env sh scripts are now bash, a strict superset needing no porting. Unrun and stated: scripts/build-web-release.sh executes only under dist in build-global-artifacts, which no branch push exercises.'). +work_done('improvise-gtn7.1',bugfix,'PR #303, merge bb01cd19. The TUI grid width is one derivation: viewport::grid_width(term_width, side_panel_open), reached through App::grid_width, is what draw.rs::draw_content splits the content row on and what App::cmd_context counts visible_cols on. The keypress path had counted from term_width while the draw gave the grid term_width minus the 32-column side panel, so with any panel open the cursor scrolled behind it. The tile bar had the same shape and now scrolls (first_tile) and draws from one tiles_left(start). Guarded by draw.rs content_tests::moving_right_keeps_the_selected_cell_drawn_beside_any_side_panel and tile_bar the_selected_tile_is_drawn_at_every_width_it_fits_in. Mixed-width columns are still wrong: improvise-gtn7.2.'). +work_done('improvise-cgqt',feature,'PR #304, merge 4f38a35e. The import wizard asks what to do with records that write one cell (ResolveDuplicates, NameDuplicateCategory), DuplicatePolicy {Sum, Separate(name), Refuse} with Refuse the default, Collision as data so every front end draws the same rows, and --on-duplicate on the headless CLI.'). +work_done('improvise-9bit',bugfix,'PR #304 (commit f00ed3e9), merge 4f38a35e. The collision check counted records with disjoint measures on one tuple as colliding; collisions_among now keeps only records that write a measure another record on the tuple also writes. Rival: the tuple-only rule, which fails only_the_records_that_share_a_cell_are_listed_and_numbered.'). +work_done('improvise-4klp',test,'PR #304 (commit f00ed3e9), merge 4f38a35e. the_name_check_covers_categories_derived_from_dates: a date-derived category name such as when_Year is refused as the occurrence category name. Green on arrival; its evidence is the reviewer rival that drops date-derived names from the taken names.'). +work_done('improvise-felo.1',feature,'PR #307, merge 1719f2cf. MAX_CATEGORIES raised from 12 to 24 in improvise-core model/types.rs, pub and re-exported as improvise_core::model::MAX_CATEGORIES; the import wizard private copy is deleted and it reads the model constant. Fill-to-the-cap tests derive the number from the constant. crates/improvise-core/examples/category_width_bench.rs measures 12 against 24; the coupling inventory is context/code/core.md.'). +work_done('improvise-felo.13',bugfix,'PR #333, merge 8da5a310. GridLayout is no longer O(rows^2): a private per-axis position index (row_pos/col_pos, rebuilt by reindex) answers cell_key/row_tuple/column_key/folds/data_to_visual in O(1); App::refresh_layout rebuilds only when layout_stale, the drawn view name or the drill snapshot pointer moved, so a cursor key rebuilds nothing; the column widths and row-header width are measured once per layout (App::grid_geometry); GridSummary/grid_summary/grid_window in ui/viewport.rs let project::grid_in evaluate only a window; DataStore::exact_positions with arities answers a full-width sorted partial with one probe. Measurements: context/test-evidence/layout-cost.md and scale-bisect.md; harness benches/scale_bisect.rs and bench/*.py.'). +work_done('improvise-31oe.3',bugfix,'PR #316, merge 66931785. The command language reads |pipe quoting| through improvise_formula::quote, so create-view |By area| names By area; a surplus argument is refused; cmd::core::only_when_omitted gates the cursor fallbacks of clear-cell, yank, paste and drill-into-cell so a typed coordinate is never overridden. context/code/cli.md section 14.'). +work_done('improvise-31oe.2',bugfix,'PR #316, merge 66931785. set-axis, set-page, toggle-group, hide-item, show-item and switch-view check at APPLY time, through the ViewOnly off-wire effect RequireKnown(Needs) built by effect_cmds::unless_unknown, that the category, item, group or view exists, and refuse and abort the batch otherwise; a refused name stops the rest of a : line, while headless improvise cmd runs each segment as its own batch (improvise-31oe.14, open). context/code/cli.md section 15.'). +work_done('improvise-8jk6.3',feature,'PR #322, merge d571abbd. A date column imports as ONE hierarchical category (Year > Quarter > Month) by default and opens folded to its years; --split-date and s on the wizard date step restore the flat split (DateShape::Split); --split-date naming a non-date field and --axis naming a category the import did not build are refused with exit 1. context/code/formula-and-io.md, Import section.'). +work_done('improvise-d0n6',bugfix,'PR #322, merge d571abbd. Plan::spelling_clash refuses a raw unparsed date spelled like a generated month, quarter or year item, because the two would silently merge; asked at the wizard ConfigureDates step and again at build time so the headless import cannot reach it.'). +work_done('improvise-8jk6.6',bugfix,'PR #321, merge 7e879702. headless import --strict: with --no-wizard any skipped record is a refusal before the build and nothing is written; without it a partial skip writes the model and warns with a count. A folder whose CSVs are all header-only is refused as an import that kept no record. context/code/cli.md section 13.'). work_followup('improvise-8tk','improvise-lae','no-file-path UX: prompt for a path rather than only erroring. ExportPrompt is CSV-only, so a new SavePrompt TextEntry mode committing to SaveAs is the likely shape.'). work_followup('improvise-6sq','improvise-ct5','i on an empty records grid reports \'cannot drill\', leaking a pivot-mode concept into records mode'). work_followup('improvise-h0b','improvise-a2x','ViewState field types (AppMode, MinibufferConfig, ViewFrame, KeyCode, KeyModifiers) still derive no Serialize, and improvise-command depends only on serde_json. Deferred: nothing serializes ViewState until improvise-cqi builds a Snapshot, so the derives would have no consumer.'). @@ -560,6 +576,11 @@ decision('improvise-i5z6',compare_the_slowest_binary_to_the_next_slowest,'The ra decision('pr-landing-2026-09-24',check_base_ref_before_merging,'Before merging ANY pull request, its baseRefName must be main. A stacked PR whose base PR has already merged still targets the stale base branch, and merging it lands the change there, not in main, while the merge reads as done. On 2026-09-24 fifteen approved stacked PRs in #255 to #286 merged into a base whose own PR had already merged; recovery took seven landing PRs (#289 to #295), each from a stack tip against main, whose titles name exactly those fifteen. Four more in that range (#259, #263, #272, #273) merged into a non-main base whose PR was still open and reached main with it, which is stacking working as intended. The repository reported delete_branch_on_merge true when measured that day, and the stale bases were still on GitHub afterwards, so that setting is not the guard: the check is. Retarget with gh pr edit --base main once the base PR has landed, then read the diff again. bd memory stacked-pr-merges-into-stale-base (which says sixteen).'). decision('improvise-3uow',ci_takes_its_tools_from_the_ci_dev_shell,'User rulings 2026-09-24. CI runs on self-hosted NixOS microVM runners, and every run: step runs inside nix develop .#ci through the workflow defaults.run.shell; a uses: step (an action) does not go through a shell at all. A missing tool goes into the .#ci shell in flake.nix, never into a per-step nix shell wrapper. No hard-coded /bin or /usr/bin paths, since those hold only env and sh there. No bash-version floors, no tool-version probes: scripts and tests must not care which bash or standard utilities are present, so checkers.sh lost its bash 5.1 pin and the dev-shell toolchain-pin step left ci.yml (#297). Playwright browsers come from nix via PLAYWRIGHT_BROWSERS_PATH, so there is no browser install step; npm ci stays for @playwright/test. A test that fakes an environment builds it on purpose (a utilbin of linked tools) rather than assuming the runner holds anything. bd memory ci-is-version-agnostic.'). decision('improvise-sboz',a_formula_edit_maps_display_names_to_ids_when_the_editor_opens,'ADR 007 clause 12 (the ADR own reading, accepted with revision 8 on PR #210). The formula panel shows display names (answer 19); the display-name-to-id mapping for every name the panel showed in that formula, the target included, is captured when the formula editor OPENS, and the commit resolves the edited text through it -- the capture-at-open rule OpenedCell already follows for cells (decision improvise-8xq0). Mapping at commit fails both ways under a concurrent rename: a name that no longer resolves refuses the edit (contradicting clause 6), and a name now owned by something else silently rebinds it (the glitch answer 3 exists to prevent). A name typed that was not captured is resolved at commit and refused, naming it, if it does not resolve; a captured id gone at commit is Rejection::Gone (clause 7); was is still compared against the stored id-form text, so a concurrent replace is still seen. Required regression test: open on Profit = Revenue - Cost, rename Revenue to Sales and another item to Revenue from a second client, commit unedited; it must still name the original id and not be refused -- failing under commit-time resolution and under refuse-on-rename.'). +decision('improvise-gtn7.1',scroll_measured_where_drawn,'A scroll offset is chosen against the same width the widget is drawn in, from one function both sides call, never from two derivations that agree by coincidence. For the grid that is viewport::grid_width; App::side_panel_open is the one answer to whether a panel took its column, so a panel that did not count toward the width would not be drawn either. For the tile bar it is tiles_left(start), where first_tile measures from and where the draw begins. context/code/tui.md, The grid width is one answer.'). +decision('improvise-cgqt',import_collisions_are_asked_not_defaulted,'PR #304, merge 4f38a35e. When records would write the same cell, the import wizard asks: ResolveDuplicates offers sum, keep separate under a new category, or go back, and appears only when ImportPipeline::collisions() is non-empty, after the field and date steps and before DefineFormulas. Silently summing was improvise-2cw9 fix for last-row-wins, and it too was a default the user never chose. The headless --no-wizard import takes the answer as --on-duplicate refuse|sum|separate. context/code/formula-and-io.md, Colliding records are a question, not a default.'). +decision('improvise-cgqt',occurrence_category_goes_on_axis_none,'Under DuplicatePolicy::Separate the build adds the occurrence category with items 1..=deepest collision and sets it to Axis::None in the active view, so the default grid still shows each tuple total and nothing new is pivoted. A record that collides with nothing is occurrence 1 (improvise-cgqt, improvise-9bit).'). +decision('improvise-felo.1',category_cap_has_one_home,'The regular-category cap is improvise_core::model::MAX_CATEGORIES and nothing else. Until felo.1 the import wizard kept a private const of its own, so raising the model number alone would have left the wizard refusing a 13th category the model accepts -- a derivation stated twice that agreed only by coincidence. The only place the literal 24 is spelled is the test add_category_max_limit; every other test that fills to the cap derives it. No recorded reason for 12 was found; the user raised it on 2026-09-25 (12 categories is feeling small, we should increase to 24).'). +decision('improvise-felo.13',skip_rebuild_keyed_on_declared_model_effects_plus_observed_view_and_drill,'Renamed from skip_rebuild_keyed_on_observed_inputs_not_effect_kind, whose name overstated. App::refresh_layout skips the rebuild unless layout_stale (DECLARED: set from EffectKind::Model(_) in apply_effects, and by install_workbook), or the drawn view name or the drill snapshot pointer (OBSERVED, compared as LayoutBasis) moved. It is not a hand-kept list of effects, the shape cursor_epoch and document_generation avoid, but it inherits gotcha 3 classification hole: a ViewOnly effect that changes what the layout reads leaves app.layout stale, where before felo.13 every batch rebuilt. A writer outside effects must call rebuild_layout itself (gotcha 23).'). wire_carries(command,both_directions,'user intent up, projection commands down'). wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport'). wire_carries(command,both_directions,'user intent client->server, projections server->client'). @@ -718,6 +739,8 @@ kb_issue_found(check_evidence_cannot_see_a_templated_test_name,'scripts/check-co kb_issue_found(k844_description_was_verbatim_e8no_body,'improvise-k844 stored description was a verbatim copy of improvise-e8no body -- the wide-numeric-category budget -- contradicting its own title. Confirmed by diffing the two DESCRIPTION blocks: identical. The agent worked the TITLE, which was right, and the defect had one good consequence: reading e8no text while working k844 is what made it check whether e8no was done, and e8no turned out to be STALE-OPEN with both its guard tests passing. A ticket carrying the wrong body is worse than an empty one, because an agent that trusts it works the wrong problem while believing it has a specification.'). kb_issue_found(the_old_checker_gate_overrode_every_shebang,'The gate in the orchestrator brief -- \`for t in scripts/*-test.sh; do sh \"$t\"; done\` -- OVERRIDES EVERY SHEBANG with /bin/sh, so on macOS it ran every suite under bash 3.2 in sh-mode regardless of what the file declared. The gate was the disease it was meant to catch. Seven further sites ran a repo script through an explicit \`sh\`, and ONE OF THEM IS dist-workspace.toml, which a first-line shebang gate STRUCTURALLY CANNOT SEE: the shebang rule closes DECLARATIONS and never INVOCATIONS, and that one was found by grepping for the CALL after fixing the declaration, not by the gate going green. Also: scripts/node-test.sh matches *-test.sh and is a test RUNNER, so a naive glob ran it with no arguments and it printed its usage INSIDE A GREEN GATE -- the rule is now that a suite is X-test.sh where X or X.sh exists beside it.'). kb_issue_found(copilot_stopped_reviewing_this_repo,'Measured 2026-09-20: PRs #12, #21 and #27 carry 2, 8 and 4 copilot-pull-request-reviewer[bot] reviews; #61, #66, #68 and #70 carry none. Both request paths reported success and registered nothing -- the GraphQL requestReviews mutation with botIds BOT_kgDOCnlnWA returned clientMutationId null, and POST /pulls//requested_reviewers with copilot-pull-request-reviewer[bot] returned 200 with requested_reviewers empty -- and no review_requested timeline event appeared. Consequence for the process at the time: AGENTS.md said \"merge only once Copilot passes\", which is an instruction to wait forever on a review that was never requested, since an ABSENT review and an UNREQUESTED one look identical from outside. That is doctrine(review_is_requested_not_inherited) seen from the requesters side. THE LAPSE ENDED: on 2026-09-24 Copilot posted inline findings on #221, #251, #274, #295 and #298, so this issue is history, not the present. AGENTS.md now says its findings are read and answered like Codex, through scripts/pr-reviews because most arrive as inline comments that gh pr view --json reviews does not show, and still that nobody waits on it: the review that gates a merge is an adversarial agent given the head SHA.'). +kb_issue_found(tui_grid_width_bug_latent_since_b5418f2e,'improvise-gtn7.1 looked like a regression and was not. compute_visible_cols took term_width from b5418f2e (2026-04-06), so the keypress path was always wrong with a side panel open; nothing opened one at launch until 50e4a372 (2026-09-20) put category_panel_open in the shared ViewState::default(), and from then every TUI session started in the broken configuration. A default changed in a shared type is a new configuration for every consumer, and a latent bug in that configuration surfaces as if the default change caused it. The tile bar offset had been wrong since 4686f470, when it first scrolled.'). +kb_issue_found(record_row_group_is_quadratic_in_records,'RecordRow::group (improvise-core view/layout.rs) finds each cell row with rows.iter().position, a linear scan, so the records-mode build is O(cells x rows). Found by the felo.1 width benchmark: the records build grows about 4x when the records double, at 12 and at 24 categories alike, so it is not category coupling. Filed as improvise-sk32.'). functional(invariant/2). functional(location/3). functional(doctrine/2). diff --git a/scripts/agent-bootstrap.sh b/scripts/agent-bootstrap.sh new file mode 100755 index 00000000..7de6d2a9 --- /dev/null +++ b/scripts/agent-bootstrap.sh @@ -0,0 +1,323 @@ +#!/usr/bin/env bash +# Stack-agnostic agent bootstrap for this repository. +# +# This script prepares any coding agent (Claude Code, Cursor, etc.) to work +# on this repo. It handles: +# 1. Nix flake input seeding (so `nix develop` works in cloud environments) +# 2. bd (beads) installation and pin verification +# 3. Issue tracker sync from the Dolt remote +# 4. Optional cargo workspace warming (inside nix develop) +# +# Usage: +# scripts/agent-bootstrap.sh [--skip-cargo-warm] [--skip-nix-seed] +# +# The script is idempotent and safe to re-run. Nothing here writes to tracked +# files, stages anything, or creates a commit. +# +# IMPORTANT: This repo requires `nix develop` (or direnv) for builds/tests. +# Bare system cargo that fails edition2024 is a misconfigured session. +# +# See also: `.claude/hooks/session-start.sh` (Claude Code wrapper), +# `.cursor/environment.json` (Cursor cloud config) +set -uo pipefail + +SKIP_CARGO_WARM=0 +SKIP_NIX_SEED=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-cargo-warm) SKIP_CARGO_WARM=1; shift ;; + --skip-nix-seed) SKIP_NIX_SEED=1; shift ;; + *) shift ;; + esac +done + +ROOT="${AGENT_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}" +cd "$ROOT" || exit 0 + +log() { printf '[agent-bootstrap] %s\n' "$*"; } + +nix_status=0 + +ensure_nix_daemon() { + command -v nix >/dev/null 2>&1 || return 1 + + # Single-user Nix does not need a daemon; multi-user/cloud installs do. + # If the store already answers, leave the environment alone. + if nix --extra-experimental-features 'nix-command' store ping >/dev/null 2>&1; then + return 0 + fi + + if ! command -v nix-daemon >/dev/null 2>&1; then + log "ERROR: Nix is installed, but the store is unavailable and nix-daemon is not on PATH" + return 1 + fi + + log "starting nix-daemon for bootstrap…" + nix-daemon >/tmp/nix-daemon.log 2>&1 & + + # The daemon is started asynchronously; wait briefly for its socket before + # invoking the seed/build steps that depend on it. + for _ in {1..50}; do + if nix --extra-experimental-features 'nix-command' store ping --store daemon >/dev/null 2>&1; then + return 0 + fi + sleep 0.1 + done + + log "ERROR: nix-daemon did not become ready; see /tmp/nix-daemon.log" + return 1 +} + +# Start the daemon during cloud-agent installation, before any required Nix +# operation. This is harmless for single-user Nix because nix-store --ping +# succeeds without starting a daemon. +if [ "$SKIP_NIX_SEED" -eq 0 ] || [ "$SKIP_CARGO_WARM" -eq 0 ]; then + if ! ensure_nix_daemon; then + nix_status=1 + fi +fi + +# ── 0. Seed nix flake inputs (required for cloud environments) ───────────── +# GitHub tarball endpoints are blocked in cloud containers. This seeds the +# flake inputs over git so `nix develop` can build the dev shell. +if [ "$SKIP_NIX_SEED" -eq 0 ]; then + if [ "$nix_status" -ne 0 ]; then + log "ERROR: cannot seed flake inputs because Nix is unavailable" + elif ! command -v nix >/dev/null 2>&1; then + log "ERROR: nix not available; required flake input seeding cannot run" + nix_status=1 + else + # Set SSL cert for proxy environments + if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /root/.ccr/ca-bundle.crt ]; then + export NIX_SSL_CERT_FILE=/root/.ccr/ca-bundle.crt + fi + + if [ ! -f "$ROOT/scripts/nix-seed-flake-inputs.sh" ]; then + log "ERROR: required scripts/nix-seed-flake-inputs.sh is missing" + nix_status=1 + else + log "seeding nix flake inputs (GitHub tarballs blocked in cloud)…" + if "$ROOT/scripts/nix-seed-flake-inputs.sh" >/tmp/nix-seed.log 2>&1; then + log "flake inputs seeded" + else + log "ERROR: nix-seed-flake-inputs.sh failed; see /tmp/nix-seed.log" + log " 'nix develop' is not validated for this session." + nix_status=1 + fi + fi + fi +fi + +# ── 1. The bd on PATH must be the bd flake.nix pins ──────────────────────── +beads_pin() { + sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \ + flake.nix 2>/dev/null | head -1 +} + +bd_running_version() { + bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1 +} + +bd_pin_banner() { + { + printf '\n[agent-bootstrap] ===== BEADS VERSION SKEW =====\n' + printf '[agent-bootstrap] %s\n' "$@" + printf '[agent-bootstrap] =============================\n\n' + } >&2 +} + +assert_bd_pin() { + command -v bd >/dev/null 2>&1 || return 0 + + pinned=$(beads_pin) + running=$(bd_running_version) + + case "$pinned" in + [0-9]*.[0-9]*.[0-9]*) ;; + *) + bd_pin_banner "cannot read the beads pin out of flake.nix" \ + "read: '${pinned:-}'" \ + "The pin moved to a ref this hook cannot parse (a branch or a" \ + "commit, perhaps). Teach beads_pin() to read it, or the check" \ + "silently protects nothing." + return 1 + ;; + esac + + if [ -z "$running" ]; then + bd_pin_banner "cannot read the version of the bd on PATH" \ + "bd: $(command -v bd)" \ + "'bd version' did not answer in the expected shape. Until it does," \ + "nothing here can tell whether that build matches the pin." + return 1 + fi + + [ "$running" = "$pinned" ] && return 0 + + bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \ + "pinned (flake.nix): $pinned" \ + "running: $running ($(command -v bd))" \ + "" \ + "Reading is safe. Do NOT run a mutating bd command — create, update," \ + "close, dep, dolt push. Version skew between builds empties the" \ + "dependency graph in .beads/issues.jsonl and reports success." \ + "" \ + "Fix now: nix develop (in this directory), then re-run the" \ + " command; or 'direnv allow' here." \ + "Fix for good: worktrees are created fresh per dispatch, so allowing" \ + " them one at a time never catches the next one. Put the" \ + " repo root under [whitelist] prefix in" \ + " ~/.config/direnv/direnv.toml." + return 1 +} + +pin_status=0 +assert_bd_pin || pin_status=2 + +# Install a verified database at .beads/embeddeddolt. +install_tracker() { + staged=$1 + live=.beads/embeddeddolt + incoming=$live.incoming + previous=$live.previous + + rm -rf "$incoming" "$previous" || return 1 + cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; } + + if [ -d "$live" ] && ! mv -f "$live" "$previous"; then + rm -rf "$incoming" + return 1 + fi + + if mv -f "$incoming" "$live"; then + rm -rf "$previous" + return 0 + fi + + if [ -d "$previous" ] && ! mv -f "$previous" "$live"; then + log "ERROR: tracker left at $previous — move it back to $live by hand" + fi + rm -rf "$incoming" + return 1 +} + +# ── 2. bd on PATH ────────────────────────────────────────────────────────── +export PATH="$PATH:/root/go/bin:$HOME/go/bin" + +BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0 +BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh" + +if ! command -v bd >/dev/null 2>&1; then + log "installing bd (beads v1.3.0)…" + if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then + log "WARNING: could not download the bd installer; see /tmp/bd-install.log." + elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then + log "bd installed" + else + log "WARNING: bd install failed; see /tmp/bd-install.log (the script that" + log " ran is at /tmp/bd-install.sh). Issue tracking unavailable." + fi +fi + +# Export PATH for the calling environment if AGENT_ENV_FILE is set +if [ -n "${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE:-}}" ]; then + env_file="${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE}}" + echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$env_file" +fi + +if [ "$pin_status" -eq 0 ]; then + assert_bd_pin || pin_status=2 +fi + +# Do not run any tracker/config mutation through a bd binary whose version does +# not match the repository pin. +if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then + bd metrics off >/dev/null 2>&1 || true +fi + +# ── 3. Sync the issue tracker from the Dolt remote ───────────────────────── +if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then + DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)" + ORIGIN="$(git remote get-url origin 2>/dev/null || true)" + case "$ORIGIN" in + git@github.com:*) + ORIGIN="https://github.com/${ORIGIN#git@github.com:}" + git remote add gh "$ORIGIN" + ;; + esac + ORIGIN="${ORIGIN%.git}" + + if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then + log "syncing issue tracker from $ORIGIN …" + STAGE="$TMP/$DB_NAME" + mkdir -p "$STAGE" + + sync_ok=0 + if ( + cd "$STAGE" && git init -q . && + BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \ + --remote "git+$ORIGIN" + ) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then + listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )" + case "$listing" in + "" | "No issues found."*) ;; + *) sync_ok=1 ;; + esac + fi + + if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then + log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl" + elif [ -d .beads/embeddeddolt ]; then + log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database." + log " It may be out of date — verify any bead against the code." + else + log "WARNING: tracker sync failed; see /tmp/bd-sync.log." + log " .beads/issues.jsonl is a STALE export — verify any bead against the code." + fi + rm -rf "$TMP" + elif [ -n "$ORIGIN" ]; then + log "WARNING: could not create a temporary directory; skipped the tracker sync." + log " .beads/issues.jsonl is a STALE export — verify any bead against the code." + fi +fi + +# ── 4. Warm the build inside nix develop (optional) ──────────────────────── +# Uses nix develop to ensure the correct toolchain (rust from flake, wasm target, etc.) +if [ "$SKIP_CARGO_WARM" -eq 0 ]; then + if [ "$nix_status" -ne 0 ]; then + log "ERROR: skipping required cargo warm because Nix bootstrap already failed" + elif ! command -v nix >/dev/null 2>&1; then + log "ERROR: nix not available; required cargo warm cannot run" + nix_status=1 + else + log "warming cargo build inside nix develop…" + NIX_FLAGS=(--extra-experimental-features 'nix-command flakes') + if nix "${NIX_FLAGS[@]}" develop --command cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then + log "workspace built (nix develop toolchain)" + else + log "ERROR: cargo build failed; see /tmp/cargo-warm.log" + log " Required nix develop validation failed." + nix_status=1 + fi + fi +fi + +# ── 5. Environment notes ─────────────────────────────────────────────────── +cat <<'NOTES' +[agent-bootstrap] Environment notes (see context/cloud-agents.md): + • This repo REQUIRES `nix develop` (or direnv) for builds and tests. + Bare system cargo that fails edition2024 is a misconfigured session. + • The bootstrap seeded flake inputs over git (GitHub tarballs are blocked + in cloud). If nix develop still fails, check /tmp/nix-seed.log. + • `bd dolt push` is blocked outbound (403). Bead status changes stay local. + • Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md + and .claude/settings.json, and commits. Use --skip-agents --skip-hooks. + • Use `scripts/kb` for knowledge base operations, not Prolog MCP. + • Run commands inside `nix develop` or prefix with `nix develop --command`. +NOTES + +exit_status="$nix_status" +if [ "$pin_status" -ne 0 ]; then + exit_status="$pin_status" +fi +exit "$exit_status" diff --git a/scripts/agent-git b/scripts/agent-git new file mode 100755 index 00000000..b87c1012 --- /dev/null +++ b/scripts/agent-git @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Run git with the commit AUTHOR named after the agent: `Claude ($BEADS_ACTOR)`. +# Why GIT_AUTHOR_NAME and not `-c user.name`: .agents/skills/wave/agent-rules.md. +set -euo pipefail + +if [ -z "${BEADS_ACTOR:-}" ]; then + echo 'agent-git: BEADS_ACTOR is unset; refusing to author as the owner' >&2 + echo 'usage: BEADS_ACTOR= scripts/agent-git commit|merge ...' >&2 + exit 2 +fi + +GIT_AUTHOR_NAME="Claude ($BEADS_ACTOR)" exec git "$@" diff --git a/scripts/agent-git-test.sh b/scripts/agent-git-test.sh new file mode 100644 index 00000000..12eb5951 --- /dev/null +++ b/scripts/agent-git-test.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# check_the_checker for scripts/agent-git (improvise-1xfb.3). A throwaway repo +# whose only identity is its own config; no global or system git config. +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUN="$here/agent-git" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 +unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); } + +# $1 name, $2 wanted, $3 got +same() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "got '$3', wanted '$2'"; fi; } + +repo="$work/repo" +git init -q "$repo" +git -C "$repo" config user.name Owner +git -C "$repo" config user.email owner@example.com +git -C "$repo" commit -q --allow-empty -m base +cd "$repo" || exit 1 +base="$(git rev-parse HEAD)" + +# The refusals: nothing may be committed, since the commit would be the owner's. +(unset BEADS_ACTOR; "$RUN" commit -q --allow-empty -m unset) > "$work/out" 2>&1 +same "an unset BEADS_ACTOR is refused" 2 "$?" +grep -q 'BEADS_ACTOR is unset' "$work/out" && ok "the refusal names BEADS_ACTOR" \ + || bad "the refusal names BEADS_ACTOR" "$(head -c 200 "$work/out")" +BEADS_ACTOR='' "$RUN" commit -q --allow-empty -m empty > /dev/null 2>&1 +same "an empty BEADS_ACTOR is refused" 2 "$?" +same "a refusal commits nothing" "$base" "$(git rev-parse HEAD)" + +# A commit: the author is the agent, and nothing else about the identity moves. +BEADS_ACTOR=agent-a1 "$RUN" commit -q --allow-empty -m one +same "the author is Claude ()" 'Claude (agent-a1)' "$(git log -1 --format=%an)" +same "the author email is the configured one" owner@example.com "$(git log -1 --format=%ae)" +same "the committer is untouched" 'Owner ' "$(git log -1 --format='%cn <%ce>')" +same "shared config is untouched" Owner "$(git config user.name)" + +# A merge commit is authored too; a plain amend keeps the agent as author. +git checkout -q -b side "$base" +BEADS_ACTOR=agent-b2 "$RUN" commit -q --allow-empty -m side +git checkout -q - +BEADS_ACTOR=agent-a1 "$RUN" merge -q --no-edit side +same "a merge commit is authored by the agent" 'Claude (agent-a1)' "$(git log -1 --format=%an)" +same "the merge has two parents" 2 "$(git log -1 --format=%P | wc -w | tr -d ' ')" + +# A cherry-pick keeps its source's author, whatever the wrapper sets. +git checkout -q -b pick "$base" +BEADS_ACTOR=agent-a1 "$RUN" cherry-pick -x --allow-empty "$(git rev-parse side)" > /dev/null 2>&1 +same "a cherry-pick keeps its source's author" 'Claude (agent-b2)' "$(git log -1 --format=%an)" +git checkout -q - + +# git's own failure is passed through, not swallowed. +BEADS_ACTOR=agent-a1 "$RUN" commit -q -m nothing-staged > /dev/null 2>&1 +rc=$? +[ "$rc" != 0 ] && ok "git's own failure is passed through" \ + || bad "git's own failure is passed through" "exit 0" + +printf '\nagent-git-test: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" = 0 ] diff --git a/scripts/build-doc-site.mjs b/scripts/build-doc-site.mjs index a2967458..8fe53cbf 100644 --- a/scripts/build-doc-site.mjs +++ b/scripts/build-doc-site.mjs @@ -82,6 +82,8 @@ const SECTIONS = [ ["context/test-evidence/cli.md", "test-evidence/cli", "The headless CLI"], ["context/test-evidence/gp.md", "test-evidence/gp", "gp-tree and improvise-gp"], ["context/test-evidence/tooling.md", "test-evidence/tooling", "The repository's own gates"], + ["context/test-evidence/layout-cost.md", "test-evidence/layout-cost", "Layout and keypress cost"], + ["context/test-evidence/scale-bisect.md", "test-evidence/scale-bisect", "Scale bisect"], ], }, { diff --git a/scripts/checkers.sh b/scripts/checkers.sh index bf837508..8409e639 100755 --- a/scripts/checkers.sh +++ b/scripts/checkers.sh @@ -101,8 +101,10 @@ ci_suites() { case "$1" in build) echo assemble-stamp ;; js) echo node-test ;; + # Needs improvise and code-maat, which only this job has (tooling.md section 7). + nix-build) echo repo-stats ;; # check-harness-lock must never read "could not resolve" as "stale" (improvise-zs2r). - kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock ;; + kb) echo kb checkers pr-watch release-preflight pr-reviews fmt-workspaces bd-audit-ids check-harness-lock agent-git ;; *) return 1 ;; esac } diff --git a/scripts/pr-watch b/scripts/pr-watch index 67222362..10844e1e 100755 --- a/scripts/pr-watch +++ b/scripts/pr-watch @@ -5,36 +5,47 @@ set -euo pipefail usage() { cat >&2 <<'EOF' -usage: pr-watch [--expect ] [--timeout ] [--interval ] +usage: pr-watch [--expect ] [--timeout ] [--interval ] [--reviews|--no-reviews] --expect fail if the PR head is not this (prefix ok) at any poll --timeout give up after this long (default 1800) --interval seconds between polls (default 20) + --reviews after checks complete, print reviews/comments (default) + --no-reviews skip the reviews/comments printout exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a - state of CLEAN, UNSTABLE or HAS_HOOKS + state of CLEAN, UNSTABLE or HAS_HOOKS (and reviews fetched if --reviews) exit 1 a check failed, or the PR is not mergeable (any other known state) exit 2 the head moved during the wait, or is not --expect exit 3 timed out with checks still running, or mergeability still UNKNOWN, or still BLOCKED with every registered check green exit 4 usage or environment error exit 5 the PR is MERGED or CLOSED, so there is nothing left to wait for +exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews") + +After exit 0, reviews and comments are printed (unless --no-reviews). Reviews are +a separate API surface from comments; Codex/ChatGPT reviews arrive as *reviews* +and are easy to miss if you only read the comment thread. EOF exit 4 } -PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20 +PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20; SHOW_REVIEWS=1 while [ $# -gt 0 ]; do case "$1" in - --expect) EXPECT="${2:-}"; shift 2 ;; - --timeout) TIMEOUT="${2:-}"; shift 2 ;; - --interval) INTERVAL="${2:-}"; shift 2 ;; - -h|--help) usage ;; - *) [ -z "$PR" ] || usage; PR="$1"; shift ;; + --expect) EXPECT="${2:-}"; shift 2 ;; + --timeout) TIMEOUT="${2:-}"; shift 2 ;; + --interval) INTERVAL="${2:-}"; shift 2 ;; + --reviews) SHOW_REVIEWS=1; shift ;; + --no-reviews) SHOW_REVIEWS=0; shift ;; + -h|--help) usage ;; + *) [ -z "$PR" ] || usage; PR="$1"; shift ;; esac done [ -n "$PR" ] || usage +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # `gh` is shell-aliased to a different tool in this repo's shells, so the name # is never trusted: a real gh is located explicitly. fetch() { @@ -130,4 +141,30 @@ echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed" # An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED # can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability". [ "$mergeable" = MERGEABLE ] || exit 1 -case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac +case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) ;; *) exit 1 ;; esac + +# ── Reviews and comments ─────────────────────────────────────────────────── +# Checks are green and mergeable. Show reviews/comments so the agent sees any +# feedback that needs answering before merge. This does not change the exit +# code for success -- answering reviews is your responsibility. However, if +# pr-reviews itself fails (usage error, network, etc.), we warn and exit 6. +if [ "$SHOW_REVIEWS" -eq 1 ]; then + echo "" + echo "pr-watch: fetching reviews and comments (--no-reviews to skip)…" + reviews_exit=0 + if [ -n "${PR_WATCH_FIXTURE_DIR:-}" ]; then + PR_REVIEWS_FIXTURE_DIR="$PR_WATCH_FIXTURE_DIR" "$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$? + else + "$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$? + fi + echo "" + if [ "$reviews_exit" -eq 0 ]; then + echo "pr-watch: reviews shown above. Answer substantive feedback before merging." + else + echo "pr-watch: WARNING: pr-reviews failed (exit $reviews_exit). Reviews may not be shown." >&2 + echo "pr-watch: Run './scripts/pr-reviews $PR' manually to fetch reviews." >&2 + exit 6 + fi +fi + +exit 0 diff --git a/scripts/pr-watch-test.sh b/scripts/pr-watch-test.sh index 59dd6531..5293388e 100755 --- a/scripts/pr-watch-test.sh +++ b/scripts/pr-watch-test.sh @@ -20,8 +20,39 @@ run_case() { [ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want" } +# Like run_case but writes output to a caller-provided file. +# Caller must NOT use command substitution (subshell loses ok/bad counters). +# $1 name, $2 expected exit, $3 fixture dir, $4 output file, rest: extra args +run_case_capture() { + local name="$1" want="$2" dir="$3" outfile="$4"; shift 4 + PR_WATCH_FIXTURE_DIR="$dir" "$WATCH" 1 --interval 0 --timeout 1 "$@" >"$outfile" 2>&1 + local got=$? + [ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want" +} + mk() { mkdir -p "$1"; cat > "$1/$2"; } +# Create empty pr-reviews fixtures so pr-watch can call pr-reviews in fixture mode. +# $1 dir, $2 head (must match the pr-watch fixture's headRefOid) +mk_reviews_empty() { + local dir="$1" head="$2" + mkdir -p "$dir" + printf '{"headRefOid":"%s","reviews":[]}\n' "$head" > "$dir/last.pr.json" + echo '[]' > "$dir/last.inline.json" + echo '[]' > "$dir/last.issue.json" +} + +# Create pr-reviews fixtures with a review. $1 dir, $2 head, $3 review commit +mk_reviews_with_review() { + local dir="$1" head="$2" review_commit="$3" + mkdir -p "$dir" + cat > "$dir/last.pr.json" < "$dir/last.inline.json" + echo '[]' > "$dir/last.issue.json" +} + # The state a fresh push is in: the PR exists, no check has registered yet. # A watcher keyed on "nothing pending" calls this finished and green. mk "$work/none" last.json <<'EOF' @@ -43,8 +74,43 @@ mk "$work/green" last.json <<'EOF' {"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}, {"name":"skipped","status":"COMPLETED","conclusion":"SKIPPED"}]} EOF +mk_reviews_empty "$work/green" "aaaaaaaaaaaa" run_case "all completed and green" 0 "$work/green" +# ── Reviews integration ──────────────────────────────────────────────────── +# pr-watch now calls pr-reviews after checks are green. Test that it works. +mk "$work/green-reviews" last.json <<'EOF' +{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[ + {"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]} +EOF +mk_reviews_with_review "$work/green-reviews" "aaaaaaaaaaaa" "aaaaaaaaaaaa" +run_case_capture "green with reviews shows review output" 0 "$work/green-reviews" "$work/out1.txt" +if grep -q "pr-reviews:" "$work/out1.txt"; then + ok "green with reviews prints pr-reviews output" +else + bad "green with reviews prints pr-reviews output" "no pr-reviews output" "pr-reviews: in output" +fi + +# --no-reviews skips the review printout +run_case_capture "--no-reviews skips review output" 0 "$work/green-reviews" "$work/out2.txt" --no-reviews +if grep -q "pr-reviews:" "$work/out2.txt"; then + bad "--no-reviews skips review output" "pr-reviews in output" "no pr-reviews" +else + ok "--no-reviews actually skips review output" +fi + +# pr-reviews failure: if pr-reviews fails, pr-watch exits 6 (not 0) +# Create a green fixture with MALFORMED review fixtures so pr-reviews fails +mk "$work/green-bad-reviews" last.json <<'EOF' +{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[ + {"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]} +EOF +# Create malformed JSON that will cause jq to fail in pr-reviews +echo 'NOT VALID JSON' > "$work/green-bad-reviews/last.pr.json" +echo '[]' > "$work/green-bad-reviews/last.inline.json" +echo '[]' > "$work/green-bad-reviews/last.issue.json" +run_case "pr-reviews failure exits 6" 6 "$work/green-bad-reviews" + mk "$work/red" last.json <<'EOF' {"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"UNSTABLE","statusCheckRollup":[ {"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]} @@ -112,6 +178,10 @@ for m in MERGEABLE CONFLICTING UNKNOWN; do fi sv="\"$s\""; [ "$s" = null ] && sv=null state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup" + # Add review fixtures for cases that exit 0 (pr-watch calls pr-reviews) + if [ "$want" = 0 ]; then + mk_reviews_empty "$work/table-$m-$s" "aaaaaaaaaaaa" + fi run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0 done done @@ -148,6 +218,7 @@ gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running" gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet" gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running" gap_state "$work/gap" last.json MERGEABLE CLEAN '.' +mk_reviews_empty "$work/gap" "aaaaaaaaaaaa" run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60 gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet" diff --git a/scripts/repo-stats b/scripts/repo-stats new file mode 100755 index 00000000..6444d2a8 --- /dev/null +++ b/scripts/repo-stats @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Churn, coupling and doc-growth report over a window of history. + +code-maat's analyses and this script's per-file churn go into one directory of +CSVs; improvise imports them into one model and computes the report's tables. +See context/code/tooling.md section 7. +""" + +import argparse +import csv +import datetime +import json +import os +import re +import subprocess +import sys +import tempfile + +# First match wins, so the order is part of the definition. +AREAS = [ + ("generated", re.compile(r"^(\.beads/|site/|Cargo\.nix$|roadmap\.org$)" + r"|\.(lock|cast)$|(^|/)package-lock\.json$")), + ("repo-map", re.compile(r"^context/repo-map\.md$")), + ("kb.pl", re.compile(r"^prolog-sessions/kb\.pl$")), + ("context/code", re.compile(r"^context/code/")), + ("context/test-evidence", re.compile(r"^context/test-evidence/")), + ("other context", re.compile(r"^context/")), + ("other docs", re.compile(r"^(docs/.*\.md|README\.md|RELEASING\.md)$")), + ("agent docs and skills", re.compile(r"^(AGENTS\.md|CLAUDE\.md|\.agents/|\.claude/|\.codex/)")), + ("scripts/CI", re.compile(r"^(scripts/|\.github/|nix/|flake\.nix$)")), + ("web and harness", re.compile(r"^(web|harness)/")), + ("rust tests", re.compile(r"(^|/)(tests|benches)/.*\.rs$|(^|/|_)tests?\.rs$")), + ("rust src", re.compile(r"\.(rs|pest)$")), +] +OTHER = "other" +PROSE = {"context/code", "context/test-evidence", "other context", "other docs", "repo-map", + "agent docs and skills"} +CODE = {"rust src", "rust tests", "scripts/CI", "web and harness"} +AGENT_RULES = "agent docs and skills" + +# One CSV per code-maat analysis, plus churn.csv. Each stem becomes an item of +# the model's File category, which the .improvise script pages on. +ANALYSES = ["coupling", "abs-churn", "age", "soc"] +CATEGORIES = ["commit", "date", "week", "entity", "area", "kind", "coupled", + "crosses-crate", "agent-rules"] +CHURN_HEADER = ["commit", "date", "week", "entity", "area", "kind", "added", "deleted"] +MEASURES = ["added", "deleted", "commits", "degree", "average-revs", "age-months", "soc"] +TABLES = ["by-area", "by-kind", "area-by-week", "by-day"] +HERE = os.path.dirname(os.path.abspath(__file__)) + + +class ImproviseFailed(Exception): + """improvise refused the pipeline's input: a product regression, not a statistic.""" + + +def area_of(path): + for name, rx in AREAS: + if rx.search(path): + return name + return OTHER + + +def kind_of(area): + return "prose" if area in PROSE else "code" if area in CODE else "other" + + +def crate_of(path): + """The crate a path belongs to, or None for a path in no crate.""" + m = re.match(r"^crates/([^/]+)/", path) + if m: + return m.group(1) + if path.startswith("harness/opfs-contract/"): + return "opfs-contract" + if re.match(r"^(src|tests|benches|examples)/", path) or path == "Cargo.toml": + return "improvise" + return None + + +def git(repo, *args): + return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True, + check=True).stdout + + +def resolve_ref(repo, ref): + """The ref asked for; the default gh/main falls back to HEAD where it is absent.""" + for r in ([ref] if ref else ["gh/main", "HEAD"]): + out = subprocess.run(["git", "-C", repo, "rev-parse", "--verify", "--quiet", + r + "^{commit}"], capture_output=True, text=True) + if out.returncode == 0: + return r, out.stdout.strip() + sys.exit(f"repo-stats: cannot resolve {ref or 'gh/main or HEAD'}") + + +def git2_log(repo, sha, since): + """The log in code-maat's git2 format: the ref's history, without merges.""" + return git(repo, "log", sha, "--no-merges", f"--since={since}", "--numstat", + "--date=short", "--pretty=format:--%h--%ad--%aN", "--no-renames") + + +def is_header(line): + return line.startswith("--") and line.count("--") >= 3 + + +def churn_rows(log): + """One row per (commit, file). A commit is written `g`, because improvise + reads an all-digit or exponent-shaped sha as a number (tooling.md section 7).""" + rows, commit, date = [], None, None + for line in log.split("\n"): + if is_header(line): + _, commit, date, _ = line.split("--", 3) + elif "\t" in line: + a, d, path = line.split("\t", 2) + year, week, _ = datetime.date.fromisoformat(date).isocalendar() + area = area_of(path) + rows.append({"commit": "g" + commit, "date": date, + "week": f"{year}-W{week:02d}", "entity": path, "area": area, + "kind": kind_of(area), "added": 0 if a == "-" else int(a), + "deleted": 0 if d == "-" else int(d)}) + return rows + + +def write_csv(path, header, rows): + with open(path, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=header) + w.writeheader() + w.writerows(rows) + + +def read_csv(path): + with open(path, newline="") as f: + return list(csv.DictReader(f)) + + +def code_maat(logfile, analysis, out, *extra): + subprocess.run(["code-maat", "-l", logfile, "-c", "git2", "-a", analysis, + "-o", out, *extra], capture_output=True, text=True, check=True) + + +def flag_coupling(path): + """code-maat's coupling.csv, plus the two flags code-maat has no notion of.""" + rows = read_csv(path) + for r in rows: + a, b = crate_of(r["entity"]), crate_of(r["coupled"]) + r["crosses-crate"] = "yes" if a and b and a != b else "no" + both = area_of(r["entity"]) == area_of(r["coupled"]) == AGENT_RULES + r["agent-rules"] = "yes" if both else "no" + write_csv(path, ["entity", "coupled", "degree", "average-revs", "crosses-crate", + "agent-rules"], rows) + + +def build_dir(out, log, rows, min_revs, today): + """The directory improvise imports: churn.csv and one CSV per analysis.""" + with tempfile.NamedTemporaryFile("w", suffix=".log", delete=False) as f: + f.write(log + "\n") + try: + revs = ["--min-revs", str(min_revs), "--min-shared-revs", str(min_revs)] + extra = {"coupling": revs, "age": ["--age-time-now", today]} + for a in ANALYSES: + code_maat(f.name, a, os.path.join(out, f"{a}.csv"), *extra.get(a, [])) + finally: + os.unlink(f.name) + flag_coupling(os.path.join(out, "coupling.csv")) + + +def improvise(out, *args): + exe = os.environ.get("IMPROVISE", "improvise") + p = subprocess.run([exe, *args], cwd=out, capture_output=True, text=True) + if p.returncode != 0 or "warning:" in p.stderr: + raise ImproviseFailed(f"improvise {' '.join(args)} exited {p.returncode}:\n" + f"{p.stderr}") + + +def run_improvise(out): + """Import the directory into one model, a view per file included, then build + the report's views and export them.""" + flags = [f"--category={c}" for c in CATEGORIES] + [f"--measure={m}" for m in MEASURES] + improvise(out, "import", ".", "--no-wizard", *flags, "--name=Repo stats", + "--formula=DeletionRatio = deleted / added FORMAT .3", + "-o", "repo-stats.improv") + os.makedirs(os.path.join(out, "report"), exist_ok=True) + improvise(out, "script", os.path.join(HERE, "repo-stats.improvise"), + "-f", "repo-stats.improv") + return {t: read_rows(os.path.join(out, "report", f"{t}.csv")) for t in TABLES} + + +def read_rows(path): + with open(path, newline="") as f: + return list(csv.reader(f)) + + +def gotchas(text): + """(number, lines, chars) for each numbered item of repo-map's Gotchas section.""" + out, cur, inside = [], None, False + for line in text.split("\n"): + if line.startswith("## "): + inside = line.startswith("## Gotchas") + continue + if inside and line.startswith("---"): + inside = False + if not inside: + continue + m = re.match(r"^(\d+)\. ", line) + if m: + cur = [int(m.group(1)), 0, 0] + out.append(cur) + if cur is not None and line.strip(): + cur[1] += 1 + cur[2] += len(line) + return [tuple(g) for g in out] + + +def doc_sizes(repo, sha): + files = [p for p in git(repo, "ls-tree", "-r", "--name-only", sha, "--", + "context").split("\n") if p.endswith(".md")] + show = lambda p: git(repo, "show", f"{sha}:{p}") + lines = {p: len(show(p).splitlines()) for p in sorted(files)} + gs = gotchas(show("context/repo-map.md")) if "context/repo-map.md" in files else [] + longest = max(gs, key=lambda g: g[2], default=None) + evidence = {p: sum(1 for l in show(p).split("\n") if re.match(r"^#{3,4} ", l)) + for p in sorted(files) if p.startswith("context/test-evidence/")} + return { + "context_lines": lines, + "repo_map": {"gotchas": len(gs), "longest": longest and + {"number": longest[0], "lines": longest[1], "chars": longest[2]}}, + "test_evidence": {"entries": sum(evidence.values()), "per_file": evidence}, + } + + +def report(repo, ref, since, min_revs, out): + name, sha = resolve_ref(repo, ref) + log = git2_log(repo, sha, since) + commits = sum(1 for line in log.split("\n") if is_header(line)) + today = git(repo, "show", "-s", "--date=short", "--format=%cd", sha).strip() + r = {"ref": name, "sha": sha, "since": since, "commits": commits, "out": out, + "docs": doc_sizes(repo, sha), "tables": {}, "coupling": [], "soc": [], "age": []} + rows = churn_rows(log) + write_csv(os.path.join(out, "churn.csv"), CHURN_HEADER, rows) + # Empty commits alone change no file: nothing to analyse, so no model is built. + if rows: + build_dir(out, log, rows, min_revs, today) + r["tables"] = run_improvise(out) + for a in ("coupling", "soc", "age"): + r[a] = read_csv(os.path.join(out, f"{a}.csv")) + return r + + +def table(rows): + """A CSV improvise exported, as aligned text; the first column left-aligned.""" + n = max(len(r) for r in rows) + widths = [max(len(r[i]) for r in rows if i < len(r)) for i in range(n)] + return [(" " + " ".join((c.ljust if i == 0 else c.rjust)(widths[i]) + for i, c in enumerate(r))).rstrip() for r in rows] + + +TITLES = { + "by-area": "Churn by area (improvise)", + "by-kind": "Prose, code and other; ProseToCode = prose / code (improvise)", + "area-by-week": "Lines added by area and ISO week (improvise)", + "by-day": "Lines added per day, churn.csv beside code-maat's abs-churn (improvise)", +} + + +def text(r, top): + out = [f"repo-stats: {r['ref']} ({r['sha'][:10]}), since {r['since']}, " + f"{r['commits']} non-merge commits"] + if not r["tables"]: + out += ["", "No file changed in the window, so no model was built."] + for t in TABLES: + if r["tables"].get(t): + out += ["", TITLES[t]] + table(r["tables"][t]) + out += ["", "Doc sizes (lines)"] + out += [f" {n:>6} {path}" for path, n in r["docs"]["context_lines"].items()] + rm, te = r["docs"]["repo_map"], r["docs"]["test_evidence"] + lg = rm["longest"] + out.append(f" repo-map gotchas: {rm['gotchas']}" + ( + f"; longest is {lg['number']} ({lg['lines']} lines, {lg['chars']} chars)" + if lg else "")) + out.append(f" test-evidence entries: {te['entries']}") + out += ["", f"Change coupling (code-maat; top {top})"] + for c in r["coupling"][:top]: + flags = [f for f, k in (("CROSSES-CRATE", "crosses-crate"), + ("AGENT-RULES", "agent-rules")) if c[k] == "yes"] + out.append(f" {c['degree']:>4}% {c['average-revs']:>4} revs {c['entity']}" + f" <-> {c['coupled']}" + (f" [{', '.join(flags)}]" if flags else "")) + out += ["", f"Sum of coupling (code-maat; top {top})"] + out += [f" {s['soc']:>6} {s['entity']}" for s in r["soc"][:top]] + out += ["", f"Code age in months (code-maat; first {top})"] + out += [f" {a['age-months']:>4} {a['entity']}" for a in r["age"][:top]] + return "\n".join(out) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--ref", help="the ref to report on (default gh/main, else HEAD)") + ap.add_argument("--since", default="7 days ago", help="git --since (default 7 days ago)") + ap.add_argument("--min-revs", type=int, default=5, + help="code-maat --min-revs and --min-shared-revs (default 5)") + ap.add_argument("--top", type=int, default=15, help="rows per ranked table") + ap.add_argument("--out", default="repo-stats", + help="the directory for the CSVs, the model and its exports") + ap.add_argument("--json", action="store_true", help="emit JSON instead of text") + ap.add_argument("-C", dest="repo", default=".", help="the repository (default .)") + a = ap.parse_args() + os.makedirs(a.out, exist_ok=True) + ours = {f"{n}.csv" for n in ["churn"] + ANALYSES} + foreign = sorted(f for f in os.listdir(a.out) if f.lower().endswith(".csv") and f not in ours) + if foreign: + print(f"repo-stats: {a.out} holds CSVs this script did not write, and " + f"`improvise import` would merge them: {', '.join(foreign)}", file=sys.stderr) + return 2 + try: + r = report(a.repo, a.ref, a.since, a.min_revs, os.path.abspath(a.out)) + except ImproviseFailed as e: + print(f"repo-stats: {e}", file=sys.stderr) + return 3 + print(json.dumps(r, indent=2) if a.json else text(r, a.top)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/repo-stats-test.sh b/scripts/repo-stats-test.sh new file mode 100755 index 00000000..2e138dbd --- /dev/null +++ b/scripts/repo-stats-test.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# check_the_checker for scripts/repo-stats (improvise-1xfb.5.1): the whole pipeline, +# code-maat and improvise included, over a fixture repo whose every number is known. +# See context/test-evidence/tooling.md. +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +STATS="$here/repo-stats" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +pass=0; fail=0 +ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } +bad() { printf ' FAIL %s (%s)\n' "$1" "$2"; fail=$((fail+1)); } + +# The improvise under test: on PATH in CI, else this tree's debug build. No build +# here: checkers.sh scrubs the linker's environment (context/code/tooling.md §7). +if command -v improvise >/dev/null; then + IMPROVISE="$(command -v improvise)" +elif [ -x "$here/../target/debug/improvise" ]; then + IMPROVISE="$here/../target/debug/improvise" +else + echo "repo-stats-test: no improvise; run \`cargo build --bin improvise\` first" >&2 + exit 1 +fi +export IMPROVISE + +# $1 name, $2 jq expression over $work/out.json, $3 the value it must print +is() { + local got + got="$(jq -c "$2" "$work/out.json" 2>&1)" + [ "$got" = "$3" ] && ok "$1" || bad "$1" "got $got, wanted $3" +} + +# $1 name, $2 table, $3 row label, $4 the row's cells after the label, as JSON +row() { + is "$1" ".tables[\"$2\"][]|select(.[0]==\"$3\")|.[1:]" "$4" +} + +# $1 name, $2 fixed-string the text report must contain +says() { + grep -Fq -- "$2" "$work/out.txt" && ok "$1" || bad "$1" "no '$2' in the text report" +} + +export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null +export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=f@x GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=f@x +repo="$work/repo" +g() { git -C "$repo" "$@" >/dev/null; } + +# $1 path, $2 line count, $3 tag. Rewriting with a new tag replaces every line. +put() { mkdir -p "$(dirname "$repo/$1")"; seq -f "$3 %g" 1 "$2" > "$repo/$1"; } +# $1 date; commits everything staged or not. +commit() { + g add -A + GIT_AUTHOR_DATE="$1T12:00:00Z" GIT_COMMITTER_DATE="$1T12:00:00Z" g commit -q -m "at $1" +} + +git init -q -b main "$repo" + +# Before the window: must count nowhere. +put README.md 50 old; commit 2026-01-01 + +# Two commits co-change six files, so each pair couples at 100% over 2 revs. +# src/ is the root crate's, so it crosses a boundary with both crates/. +for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 10 x1; done +put src/main.rs 5 x1; put crates/a/tests/t.rs 4 x1 +put AGENTS.md 3 x1; put .agents/skills/wave/agent-rules.md 3 x1 +commit 2026-06-02 +for f in crates/a/src/lib.rs crates/b/src/lib.rs; do put "$f" 6 x2; done +put src/main.rs 5 x2; put crates/a/tests/t.rs 4 x2 +put AGENTS.md 3 x2; put .agents/skills/wave/agent-rules.md 3 x2 +commit 2026-06-03 + +# One file in each remaining area. The repo map is 13 lines. +put context/code/x.md 8 x3 +mkdir -p "$repo/context/test-evidence" +printf '### t_one\ntext\n#### t_two\n## not an entry\n### t_three\n' \ + > "$repo/context/test-evidence/y.md" +cat > "$repo/context/repo-map.md" <<'EOF' +# Map + +## Gotchas (read before editing) + +1. **One.** short. +2. **Two.** a much longer gotcha, with plenty of words in it to be the longest one + and a second line. + +3. **Three.** middling length. + +--- + +4. not a gotcha, because the section has ended +EOF +put context/plan.md 2 x3 +put prolog-sessions/kb.pl 5 x3 +put scripts/foo.sh 7 x3; put .github/workflows/ci.yml 1 x3 +put web/app.js 9 x3; put harness/x.js 2 x3 +put Cargo.lock 11 x3 +put crates/a/src/grammar.pest 3 x3 +put .beads/issues.jsonl 2 x3; put site/index.html 1 x3 +put docs/guide.md 2 x3; put .codex/config.toml 1 x3 +commit 2026-06-04 +g tag before-shrink + +# A side branch merged with a merge commit: the side commit counts, the merge does not. +g checkout -q -b side +put notes.txt 4 x5; commit 2026-06-05 +g checkout -q main +GIT_AUTHOR_DATE=2026-06-06T12:00:00Z GIT_COMMITTER_DATE=2026-06-06T12:00:00Z \ + g merge -q --no-ff -m merge side + +# A shrink in the next ISO week, so one area deletes and one row spans two weeks, +# and a rename, which --no-renames counts as a delete and an add. +put web/app.js 3 x4 +g mv scripts/foo.sh scripts/bar.sh +commit 2026-06-09 + +# $1 output dir, $2 "text" to also write the text report, rest: extra args. +run() { + local out="$1" text="$2"; shift 2 + "$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" --json \ + > "$work/out.json" 2> "$work/err" \ + || { bad "repo-stats $* ran" "$(cat "$work/err")"; return 1; } + [ "$text" = text ] || return 0 + "$STATS" -C "$repo" --since 2026-06-01 --min-revs 2 --out "$out" "$@" \ + > "$work/out.txt" 2>&1 \ + || { bad "repo-stats $* ran (text)" "$(cat "$work/out.txt")"; return 1; } +} + +if run "$work/stats" text; then + is "no gh/main in the fixture, so the ref is HEAD" '.ref' '"HEAD"' + is "the merge commit is not counted, the side commit is" '.commits' '5' + row "rust src is .rs and .pest outside tests" by-area "rust src" '["45","25","0.556"]' + row "rust tests are the tests/ files" by-area "rust tests" '["8","4","0.500"]' + row "agent docs and skills take .codex" by-area "agent docs and skills" '["13","6","0.462"]' + row "docs/*.md is other docs" by-area "other docs" '["2","0","0.000"]' + row "context/code" by-area "context/code" '["8","0","0.000"]' + row "context/test-evidence" by-area "context/test-evidence" '["5","0","0.000"]' + row "repo-map is its own area" by-area "repo-map" '["13","0","0.000"]' + row "other context" by-area "other context" '["2","0","0.000"]' + row "kb.pl" by-area "kb.pl" '["5","0","0.000"]' + row "scripts/CI takes .github, and a rename is a delete and an add" \ + by-area "scripts/CI" '["15","7","0.467"]' + row "web and harness, and its deletion ratio" by-area "web and harness" '["14","9","0.643"]' + row "a lock file, .beads/ and site/ are generated" by-area "generated" '["14","0","0.000"]' + row "the pre-window README counts nowhere" by-area "other" '["4","0","0.000"]' + row "prose is the context areas, other docs and agent docs" by-kind prose '["43","6","0.140"]' + row "code is rust, scripts/CI and web" by-kind code '["82","45","0.549"]' + row "prose to code is prose over code" by-kind ProseToCode '["0.524","0.133",""]' + is "weeks are columns, first-seen first" '.tables["area-by-week"][0][1:]' '["2026-W24","2026-W23"]' + row "an area is split by ISO week" area-by-week "web and harness" '["3","11"]' + is "every day's churn.csv total equals code-maat's abs-churn" \ + '[.tables["by-day"][1:][]|.[1]==.[2]]|all' 'true' + row "a day's total is every file changed that day" by-day 2026-06-04 '["72","72"]' + is "gotchas end with their section" '.docs.repo_map.gotchas' '3' + is "the longest gotcha is measured over its lines" \ + '.docs.repo_map.longest|[.number,.lines]' '[2,2]' + is "test-evidence entries are ### and #### headings" '.docs.test_evidence.entries' '3' + is "coupling finds the 15 pairs of six files, and web/app.js with scripts/foo.sh" \ + '.coupling|length' '16' + is "files of two crates, the root crate's included, cross a boundary" \ + '[.coupling[]|select(.["crosses-crate"]=="yes")|[.entity,.coupled]|sort]|sort' \ + '[["crates/a/src/lib.rs","crates/b/src/lib.rs"],["crates/a/src/lib.rs","src/main.rs"],["crates/a/tests/t.rs","crates/b/src/lib.rs"],["crates/a/tests/t.rs","src/main.rs"],["crates/b/src/lib.rs","src/main.rs"]]' + is "two agent-rule docs are flagged" \ + '[.coupling[]|select(.["agent-rules"]=="yes")|[.entity,.coupled]|sort]' \ + '[[".agents/skills/wave/agent-rules.md","AGENTS.md"]]' + says "the text report flags a crate crossing" "[CROSSES-CRATE]" + says "the text report flags agent-rule docs" "[AGENT-RULES]" + says "the text report prints improvise's ratio row" "ProseToCode" + views="$(grep -c '^## View: ' "$work/stats/repo-stats.improv")" + [ "$views" = 10 ] && ok "the model keeps a view per file and the report's four" \ + || bad "the model keeps a view per file and the report's four" "$views views" + # Sparse, not padded: a padded merge has the same numbers and views, but a + # `-` item in every category and per-file views seated on the padding. + dashes="$(grep -c '|-|' "$work/stats/repo-stats.improv")" + [ "$dashes" = 0 ] && ok "the sparse model has no padding item" \ + || bad "the sparse model has no padding item" "$dashes lines name |-|" + churn_view="$(awk '/^## View: churn$/,/^$/' "$work/stats/repo-stats.improv")" + for seat in "date: row" "agent-rules: none" "coupled: none" "crosses-crate: none"; do + grep -qx -- "$seat" <<< "$churn_view" && ok "the churn view seats $seat" \ + || bad "the churn view seats $seat" "$(tr '\n' ' ' <<< "$churn_view")" + done +fi + +# An --out holding a CSV this script did not write is refused, since the folder +# import would merge it. The script's own CSVs, from a rerun, are not foreign. +mkdir -p "$work/foreign"; echo "x,y" > "$work/foreign/other.csv" +"$STATS" -C "$repo" --since 2026-06-01 --out "$work/foreign" > /dev/null 2>&1 +got=$? +[ "$got" = 2 ] && ok "a foreign CSV in --out is refused with exit 2" \ + || bad "a foreign CSV in --out is refused with exit 2" "exit $got" + +# The window is scoped to the ref: at the tag, the shrink has not happened. +if run "$work/at-tag" json --ref before-shrink; then + row "--ref scopes the log" by-area "web and harness" '["11","0","0.000"]' + is "--ref names the ref it read" '.ref' '"before-shrink"' +fi + +# A window whose only commits are empty changes no file: exit 0, and no model. +empty="$work/empty" +git init -q -b main "$empty" +repo="$empty"; put README.md 5 old; commit 2026-01-01 +GIT_AUTHOR_DATE=2026-06-02T12:00:00Z GIT_COMMITTER_DATE=2026-06-02T12:00:00Z \ + g commit -q --allow-empty -m empty +if run "$work/empty-out" text; then + is "an empty commit is counted" '.commits' '1' + is "and builds no tables" '.tables' '{}' + lines="$(wc -l < "$work/empty-out/churn.csv" | tr -d ' ')" + [ "$lines" = 1 ] && ok "churn.csv is written with its header alone" \ + || bad "churn.csv is written with its header alone" "$lines lines" + says "the text report says why there are no tables" "No file changed in the window" +fi +repo="$work/repo" + +# improvise refusing the pipeline is exit 3, whatever the numbers were. The +# third stub imports cleanly and refuses only the scripts. +mkdir -p "$work/bin" +printf '#!/usr/bin/env bash\necho "Parse error: no" >&2\nexit 1\n' > "$work/bin/refuses" +printf '#!/usr/bin/env bash\necho "warning: Skipped 3 of 3 records" >&2\nexit 0\n' \ + > "$work/bin/warns" +printf '#!/usr/bin/env bash\n[ "$1" = script ] || exit 0\necho "Parse error: no" >&2\nexit 1\n' \ + > "$work/bin/refuses-a-script" +chmod +x "$work/bin/refuses" "$work/bin/warns" "$work/bin/refuses-a-script" +for stub in refuses warns refuses-a-script; do + IMPROVISE="$work/bin/$stub" "$STATS" -C "$repo" --since 2026-06-01 --out "$work/$stub" \ + > /dev/null 2>&1 + got=$? + [ "$got" = 3 ] && ok "an improvise that $stub fails the pipeline with exit 3" \ + || bad "an improvise that $stub fails the pipeline with exit 3" "exit $got" +done + +printf '\nrepo-stats: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" = 0 ] diff --git a/scripts/repo-stats.improvise b/scripts/repo-stats.improvise new file mode 100644 index 00000000..ce08f088 --- /dev/null +++ b/scripts/repo-stats.improvise @@ -0,0 +1,87 @@ +# The views scripts/repo-stats builds over the model it imported, run by +# `improvise script` with the output directory as the working directory. +# Every `export` here is a table the text report prints. See +# context/code/tooling.md section 7. + +# The prose-to-code ratio is a formula over the kind category's own items. +add-formula kind "ProseToCode = prose / code FORMAT .3" + +# Churn by area, with the deletion ratio (a _Measure formula from the import). +create-view "By area" +switch-view "By area" +set-axis File page +set-page File churn +set-axis area row +set-axis _Measure column +set-axis kind none +set-axis week none +set-axis date none +set-axis commit none +set-axis entity none +set-axis coupled none +set-axis crosses-crate none +set-axis agent-rules none +toggle-prune-empty +set-format .0 +export report/by-area.csv + +# Prose, code and other, and the ratio of the first two. +create-view "By kind" +switch-view "By kind" +set-axis File page +set-page File churn +set-axis kind row +set-axis _Measure column +set-axis area none +set-axis week none +set-axis date none +set-axis commit none +set-axis entity none +set-axis coupled none +set-axis crosses-crate none +set-axis agent-rules none +toggle-prune-empty +set-format .0 +export report/by-kind.csv + +# Lines added per area per ISO week. +create-view "Area by week" +switch-view "Area by week" +set-axis File page +set-page File churn +set-axis area row +set-axis week column +set-axis _Measure page +set-page _Measure added +set-axis kind none +set-axis date none +set-axis commit none +set-axis entity none +set-axis coupled none +set-axis crosses-crate none +set-axis agent-rules none +toggle-prune-empty +set-format .0 +export report/area-by-week.csv + +# Lines added per day, from both sources side by side: this script's churn +# rows and code-maat's abs-churn. The two columns must agree. +create-view "By day" +switch-view "By day" +set-axis date row +set-axis File column +set-axis _Measure page +set-page _Measure added +set-axis area none +set-axis kind none +set-axis week none +set-axis commit none +set-axis entity none +set-axis coupled none +set-axis crosses-crate none +set-axis agent-rules none +toggle-prune-empty +set-format .0 +export report/by-day.csv + +switch-view "By area" diff --git a/site/docs/adr/001-concurrent-edits.html b/site/docs/adr/001-concurrent-edits.html deleted file mode 100644 index 7d1f4823..00000000 --- a/site/docs/adr/001-concurrent-edits.html +++ /dev/null @@ -1,99 +0,0 @@ - - - - - -ADR 001 — Concurrent edits — improvise docs - - - -
- -
-

Source: context/adr/001-concurrent-edits.md

On this page

ADR 001 — Concurrent edits between clients

-

Status: accepted (2026-09-19); layer 1 implemented (improvise-xwi6/fkfg, 2026-09-19) · Supersedes: nothing · Decided by: the user

-

#Context

-

improvise-cqq made the client/server split real: one authoritative App, a ClientSession mirror per client, and projections diffed against those mirrors. improvise-kmgs then put the 28 model mutations on the wire. Two clients can now edit the same document, and nothing decides what happens when they edit the same part of it.

-

#There is already a total order

-

Every ModelCommand reaches the document through one path: Sessions::apply → apply_command → App::apply_effects, against a single App. So the server already serialises writes. We do not need to build linear history — it exists.

-

What we lack is a way to notice that a client composed an edit against a picture of the document that has since moved. This is a stale basis problem, not an ordering problem — the same shape as the stale cursor (gotcha 24) and the stale session mirror after a document swap (gotcha 39), one level up.

-

#Scope: conflicts range over one document

-

improvise is a multi-document app, and the server is expected to host several separate models. Sessions editing independent models are trivially non-conflicting, so everything below scopes to a single document.

-

That falls out of the existing shape rather than needing to be built: Sessions does not own the App — connect, apply and project_all all take it as a parameter — so a Sessions is already bound to one document's generation sequence, and multi-document hosting is a registry above it mapping a document to its (App, Sessions) pair. The version tiers this ADR introduces live on the App, so they scope per document for free.

-

Multi-document hosting landed as improvise-ws-server::registry (improvise-j31x): a Registry of Hosts keyed by DocumentId, addressed by ClientId { document, session } because a SessionId is minted per Sessions and so is unique only within its document. The version tiers scope per document as predicted. Note one lifecycle rule that exists to protect this ADR: a document is never evicted while the server runs, because document_generation and record_versions start at zero in a fresh App, so re-opening would accept a reconnecting client's stale basis as current.

-

#What actually conflicts

-
    -
  1. Two writers, same record. Survivable under last-write-wins: someone's number is lost, the document stays coherent.
  2. -
  3. Shape change vs. cell edit. A removes category Region while B commits an edit keyed by Region=East. B's CellKey names a coordinate that no longer exists. This is corruption, not a lost keystroke, and it is the class a cell-level lock does not cover — holding Region=East × Revenue does not stop anyone deleting Region.
  4. -
  5. Formula vs. data. A adds a formula targeting the measure B is typing into; B's raw value is shadowed by a derived one.
  6. -
-

#Machinery that already exists

-
    -
  • App::document_generation — bumped by install_workbook, consumed by ClientSession::project to reseed a stale mirror (gotcha 39).
  • -
  • ModelChange::{CellData, LayoutGeometry, WholeWorkbook} — the effect taxonomy, already the single classification for what a mutation reaches (gotcha 35).
  • -
  • record_row_identity(wide, coord_group_excluding_measure) — a record is the coordinate tuple excluding _Measure (gotcha 1).
  • -
  • Drill staging — pending_edits applied atomically by ApplyAndClearDrill, and ruled private to whoever staged them (improvise-tom1).
  • -
-

#Machinery that did not exist, and the hole it left

-

Nothing in the projection stream carried any of the three tiers. This ADR says a batch carries the basis it was composed against and never says how a client comes to know one — so the check as originally specified was satisfiable only by a client forging a basis. That is the invariant deleted with extra steps, arriving through the front door rather than as a force flag.

-

Closed during implementation (improvise-fkfg) by Projection::Basis + BasisCache: the server publishes the two document tiers plus the write counters for the records this client's grid names — exactly the set it can address a write to, per ADR 002 — diffed against what that client was last told and seeded in the bootstrap. It is emitted after the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown. That is the mirror image of gotcha 33.

-

Read this as step (a′) of the implementation order below: publication comes before the check, because the check is unsatisfiable without it.

-

#Decision

-

Record-level atomicity with optimistic concurrency per batch.

-
    -
  1. The unit of atomicity is the record, i.e. the coordinate tuple excluding _Measure — the thing record_row_identity already names. Not the cell: gotcha 1 says editing a category column in records mode moves every measure cell in the row, so a cell-level unit would be violated by a single legal edit. The unit must be at least as coarse as the coarsest legal write.
  2. -
  3. The unit of commit is the batch — one Vec<Box<dyn Effect>> applied by one apply_effects. This is already atomic in practice: a Binding::Sequence flattens into one batch, and a failed Save aborts the rest (gotcha 21).
  4. -
  5. A batch carries the basis it was composed against, and the server rejects it if that basis moved. The basis is tiered by ModelChange, which is what makes class 2 fall out for free:

    -
    tierbumped bya batch is stale if
    document_generationWholeWorkbookit moved at all
    layout_versionLayoutGeometryit moved at all
    record_versionCellData on that recordany record the batch touches moved
    -

    A CellData batch is stale if layout moved, because the shape its keys hang off changed. That is class 2, detected without a lock.

  6. -
  7. Rejection is a normal outcome, not an error. The client already receives the new state through projections; it re-composes against the new basis. Applied grows a third case alongside Diff and Rebased — matching is what forces a host to handle it (gotcha 39's reasoning).
  8. -
-

#Rejection must be forceable, and force means re-compose

-

A rejected edit has to give the user a way through. But force is a re-composition, not a bypass — it never applies a batch whose basis is stale. A mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps. Force re-composes the same intent against the current state and submits a fresh batch with a current basis.

-

That distinction is not pedantic, because what force can mean differs by class:

-
classcan force?what force does
1 — two writers, same recordyes, triviallyre-compose and apply; the user has seen their value and chooses to overwrite it
2 — shape change vs. cell editnothe intent no longer type-checks: the key names a coordinate that no longer exists
3 — formula shadows a valueyes, with a warningthe write lands; the displayed value is still derived
4 — the batch names a record its basis says nothing aboutno — and it is not a racenothing to re-compose against; the client was never in the running
-

Class 4 was not in the original draft and was added by the implementation (Rejection::RecordUnstated). It exists because "any record the batch touches" had to name whose list, and the answer is the server's: reading it off the client's claim would be a force by omission — a client that simply omits a record skips the record tier, with no flag to remove and nothing in the message saying so. reduce.rs::records_named is therefore an exhaustive read of the ModelCommand, with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. A well-formed client cannot produce class 4; it is a case of its own rather than folded into class 1 because "you lost a race" and "you were never in one" are different things to tell a user.

-

For class 1, force is one keystroke and should be. For class 2 there is nothing coherent to force: applying the batch anyway writes a CellKey naming a deleted category, which is the ghost-coordinate corruption this ADR exists to prevent. So:

-
    -
  • Force never creates structure. Re-instating a category someone deleted so an edit can land is a different and larger intent than the user expressed, and it must be an explicit action, not a side effect of forcing a cell edit.
  • -
  • When an intent cannot be re-composed, the UI owes the user what changed and the concrete alternatives — re-target the edit, or re-create the structure first — rather than a refusal.
  • -
-

Two consequences that pull work forward from layer 2:

-
    -
  • The rejected batch's values stay staged in the session, so rejection never costs the user their typing. This is the drill staging mechanism doing the job it already does, and it is what makes force cheap rather than a retype. It belongs in layer 1 even though publishing the staged keys is layer 2.
  • -
  • A forced overwrite must be visible to the client it overwrote. Otherwise force is silent data loss with a confirmation step in front of it. The loser's own edit was already committed, so this is a notification, not a conflict — but it must exist, or "someone else won" becomes indistinguishable from "my edit never happened."
  • -
-

#Optional second layer: advisory reservations

-

The staged-edit mechanism from drill, generalised: a client publishes the keys it has staged so others can grey them out and warn early. The values stay private, per improvise-tom1.

-

This layer is advisory only. If a reservation is lost, never arrives, or leaks from a crashed client, the system degrades to "you find out at commit" — never to corruption. That is what makes it safe to be sloppy about timeouts and disconnects, and it is why correctness must not be built on it.

-

#Why not a causal clock

-

Considered and rejected, for four reasons.

-
    -
  1. The server already serialises. Causal clocks earn their keep where no single node orders writes — peer-to-peer, or offline-capable clients merging on reconnect. With an authoritative App we need staleness detection (one counter per unit), not concurrency detection (a vector per client).
  2. -
  3. A Lamport clock is insufficient. It gives a total order but cannot tell you two edits were concurrent, which is exactly the question.
  4. -
  5. A vector clock is sufficient but does not answer the question that matters. It costs O(clients), needs entries reaped for departed clients, and when it reports "these were concurrent" you still need a policy for class 2 — no clock says whether B's edit into Region=East should survive A's deletion of Region. The clock tells you there is a decision to make; it does not make it.
  6. -
  7. CRDTs converge, but not necessarily to something meaningful. A LWW-register per cell is easy; the hard part is the shape — categories, items, formulas, axis assignments — where convergence can mean an orphaned cell or a resurrected category.
  8. -
-

If offline editing ever becomes a goal, this decision should be revisited: that is the requirement that makes a vector clock worth its cost, and it is a much larger commitment than multi-client-while-connected.

-

#Consequences

-

Good. No lock to acquire, so no timeout, no lease renewal, no disconnect handling, and no liveness risk — a crashed client cannot wedge anyone. Correctness does not depend on the advisory layer. The tiered basis reuses ModelChange, so there is no second classification to drift from gotcha 35's table. Class 2 is detected rather than prevented, which is cheaper and does not require predicting which shape changes matter.

-

Bad. A client can compose an edit and be told no, and the UI owes real work there: keep the typing, show what moved, and offer force where force is coherent. Class 2 is the expensive case — the honest answer is a small dialogue about what changed, not a button. Record-level granularity means two clients editing different measures of the same record serialise against each other even though their writes do not overlap — acceptable at the expected scale, and refinable to (record, measure) if contention shows up. A busy LayoutGeometry editor invalidates every in-flight CellData batch, which is correct but could feel aggressive.

-

Not solved here. Class 3 (formula shadows a raw value) is detected — the formula bumps layout_version — but the policy is unstated: the edit is rejected, and whether that is the right answer for the user is a separate question. Nor does this address a client that wants to keep editing while disconnected.

-

#Implementation order

-

Deliberately two tickets, so the correctness floor lands without waiting on the UX.

-
    -
  1. Basis check. Version tiers, ModelCommand carries its basis, Applied grows the rejected case. Includes the decision of where the version counters live — they are shared document state, so they belong beside document_generation on the authoritative App, not on ViewState.
  2. -
  3. Advisory reservations. Generalise drill staging; publish key sets, keep values private.
  4. -
-

The nullary wire variants ApplyAndClearDrill and AcceptWizardModel were to be resolved first or alongside. Already done: improvise-3x3u removed both from ModelCommand entirely, so they have no wire form to carry a basis or a SessionId, and layer 1 landed honestly without them (improvise-k2i4 closed as stale). What survives is the addressed form multi-client drill will want, which is layer 2 work.

-

#What the check is not

-

It is an anti-staleness coordination check, not a security boundary. Basis derives Deserialize because it must cross the wire, and every field is public, so any value at all can arrive as a claim — versions start at 0 and increment by 1, so guessing one is trivial. Rejection::RecordUnstated closes omission — a client that leaves a record out of its claim — which is the bypass that would otherwise sit in the message format with nothing saying so. It does not close invention, and nothing here tries to: a hostile client that forges a current basis is indistinguishable from one that is genuinely caught up, and defending against that is authentication's job.

-

BasisCache::claim being the only constructor is therefore a statement about clients written against this crate, not about the wire. It is still worth having: it makes the honest path the easy one, and it is what makes "a client that has folded nothing composes nothing" true by construction.

-

#Two places the check deliberately does not reach

-

Command::SwitchView carries no basis. It is LayoutGeometry but sits outside Command::Model, so apply_switch is unchecked. It cannot corrupt — the workbook refuses a view it does not have, and every mirror takes what was accepted (gotcha 36) — but it is a hole in "a batch carries its basis" and should be decided rather than left as an accident.

-

apply_command does not check at all (nor did reduce_full, which improvise-s17o has since deleted for having no caller), and the principle is correct: the check is a property of the session door, not of the mutation path, because a host holding the App is the truth and not a claimant. That is also why ui::reduce::caught_up(&App, cmd) is not a bypass — it takes &App, so nothing reachable from a wire can call it.

-

As implemented it was a second door, not an exemption — both functions took a wire Command whose Command::Model { cmd, .. } arm discarded the basis with .., while ui/reduce.rs's module doc pointed every future host at them by name. Closed by improvise-ondj as layer-1 cleanup: both now take a Checked<'_>, whose only constructor runs the check against the authoritative App and returns a Rejection. reduce_full was deleted outright shortly after (improvise-s17o): having no caller was the condition that let the defect hide, and a Checked signature on a function nobody calls fixes the type and not that. The exemption stands — a host's own ModelCommand still carries no basis — but it is no longer reachable as a door, and the unchecked path is unconstructible rather than merely discouraged.

-
-
- - diff --git a/site/docs/adr/002-naming-a-cell.html b/site/docs/adr/002-naming-a-cell.html deleted file mode 100644 index 0c04a8a1..00000000 --- a/site/docs/adr/002-naming-a-cell.html +++ /dev/null @@ -1,82 +0,0 @@ - - - - - -ADR 002 — Naming a cell — improvise docs - - - -
- -
-

Source: context/adr/002-naming-a-cell.md

On this page

ADR 002 — How a client names a cell

-

Status: accepted (2026-09-19), extended (2026-09-20) · Decided by: the user · Blocks: improvise-1ey · Extended by: improvise-ir7l, improvise-poia

-

#Context

-

improvise-q08 established that a thin client cannot compose SetCell, and it is the gap that blocks the browser MVP, whose stated scope is "type a number into a cell and see the value update".

-

A client sees a grid of label strings indexed by viewport position. A CellKey is the full sorted coordinate tuple — every Row and Column category, plus every Page and Filter selection (gotcha 27). Nothing in GridCache says which category a row label belongs to; nested row axes flatten several categories into one label; page and filter constraints are not projected at all; and records mode encodes a column index in _Dim (gotcha 1). There is no client-side derivation, and projecting the coordinate space would make every client learn conventions the server exists to own.

-

#Decision

-

Every projected cell carries an opaque surrogate identity, and the kind of identity says what may be done to it.

-
pub struct CellDisplay {
-    pub text: String,
-    pub numeric: bool,
-    pub error: bool,
-    pub id: CellId,
-}
-
-pub enum CellId {
-    /// A stored cell. `SetCell` takes this and nothing else.
-    Record(RecordHash),
-    /// An aggregate over stored cells. `Drill` takes this and nothing else.
-    Aggregate(AggregateHash),
-}
-

An aggregate's surrogate is a hash of its coordinate constraint — the row and column coordinates plus the page and filter selections that define which stored cells it sums. One hash per cell, the same width and the same index machinery as a record's, and the server resolves it back and runs drill_records when the client asks.

-

Three things follow, and each is the reason to prefer this over alternatives.

-

1. Both operations are unrepresentable-if-wrong, not checked. A client cannot compose SetCell for an aggregate, and cannot compose Drill for a stored cell, because neither carries an id of the other's type. That is design-principles §3 — make invalid states unrepresentable — and it is strictly stronger than a server-side rejection, which would be a runtime answer to a question the type can settle.

-

An earlier draft used Option<CellId>, absence meaning "aggregate". The enum is better for a reason worth keeping: an aggregate is not the absence of an identity, it is a different kind of identity. Modelling it as None made SetCell safe and left Drill with nothing to address — which is precisely how drill addressing came to be an open question. Two variants close it by construction.

-

2. The client never learns the coordinate space. No category names, no page/filter constraints, no _Dim column-index convention. The surrogate is a token to echo, not a structure to understand — so gotchas 1 and 27 stay knowledge the server owns, and a second client implementation cannot get them subtly wrong.

-

3. It mirrors a fork that already exists. EditOrDrill (cmd/mode.rs) already branches on exactly this in the TUI: a stored cell is edited, an aggregate is drilled. The wire is not inventing a distinction, it is projecting one the model already makes — the same discipline as decision(improvise-cqi, command_set_derived_not_invented).

-

It also follows the precedent CellDisplay::numeric set, whose doc already says the server decides "because it is the side that knows which it was". Settability is another such fact.

-

#Decisions made along the way, and the traps in them

-

Most of what started as open questions here got answered. They are kept with their reasoning because each rejected alternative is a thing someone will propose again.

-

What the surrogate is — DECIDED: a hash of the normalized record. Not of the cell: the record is the coordinate tuple minus _Measure, which is already ADR 001's unit of atomicity and already what record_versions is keyed by. A cell is then (record hash, measure).

-

The reason is that it makes stale edits cheap to identify, and it does so three ways at once. The surrogate a client already holds is the lookup key for its own version, so record_versions becomes HashMap<RecordHash, u64> — fixed-size, no allocation, no CellKey reconstruction on the hot path. A basis is then a list of (record hash, version) pairs, which is compact on the wire and O(1) to check. And a record that no longer exists simply fails to resolve, so that whole class of staleness needs no version comparison at all. One identity serves addressing and staleness instead of each ADR carrying its own.

-

It is well-defined because CellKey already has a canonical form: CellKey::new sorts coordinates by category name and equality and hashing rely on it (gotcha 5). "Normalized" means that canonical sorted form with _Measure removed — the normalization is a property the type already guarantees, not a new convention to maintain.

-

An interned id was rejected: it would need a table with a lifetime nobody has specified, and it buys only collision-freedom, which width buys more cheaply.

-

Three ways this silently breaks, all avoidable. They are listed because each produces a wrong write rather than an error, which is the failure mode this ADR exists to prevent:

-
    -
  • Do not use DefaultHasher or anything built on RandomState. std's default HashMap hasher is randomly seeded per process, so hashes would differ across a server restart and between any two processes. Specify a fixed hash (a keyed SipHash with a constant key, or FxHash/xxHash/blake3) and write the choice down.
  • -
  • Do not use #[derive(Hash)] as the definition. Rust gives no stability guarantee for derived Hash across compiler versions or platforms. Hash an explicit byte serialization of the sorted (category, item) pairs, so the surrogate is defined by the format rather than by whatever the compiler did.
  • -
  • Pick the width by consequence, not by convenience. A collision is a silent write to the wrong record — precisely why _Index was rejected. 64 bits reaches birthday risk around 2³² records, which a pivot tool will not see; 128 bits costs 8 more bytes per visible cell, so on a 50×20 viewport that is 8 KB a frame. The cost is small and the failure is silent, so argue the width explicitly rather than defaulting to u64 because it is convenient.
  • -
-

_Index was considered and rejected — right ontology, unsafe representation. It is the coordinate that identifies a record, and a record is exactly the thing that is settable rather than aggregated, so the instinct is sound. But CategoryKind::VirtualIndex is documented as "Items are "0", "1", … N where N = number of matching cells" — it is positional over the currently matching set, not an identity. So _Index = "3" names a different record after a filter change, an insert, or a delete, and names a different one again in another view. A client echoing a stale _Index back would write to the wrong cell, silently — strictly worse than a surrogate that fails to resolve, and the same hazard class as the viewport-relative-vs-absolute trap and the stale cursor under a renamed key.

-

What _Index is pointing at is worth keeping, though: the surrogate should name the record, not the cell. A record is the coordinate tuple minus _Measure (record_row_identity), which is already ADR 001's unit of atomicity and already what record_versions is keyed by. If a cell is addressed as (record surrogate, measure), then one identity serves both addressing and staleness, and the two ADRs share a mechanism instead of each inventing one.

-

How the server resolves a surrogate back — DECIDED: a maintained index, in DataStore. A hash is one-way, so resolution needs a reverse map, and regenerating it from the layout on demand would be O(visible cells) per resolve and would only ever cover the visible window — a client may legitimately hold a surrogate for a record that has scrolled out of view.

-

DataStore is the right home because it already does exactly this. It interns every category and item name into a SymbolTable, and it already maintains a secondary index HashMap<(Symbol, Symbol), HashSet<InternedKey>> for hot-path lookups. A RecordHash → record index is the same shape, updated at the same points, and the existing (Symbol, Symbol) index is what makes invalidation tractable: when a category or item disappears, the records that mention it are findable without a scan.

-

Two consequences worth stating, because they fall out and are easy to miss.

-

The index and record_versions want to be one structure. Both are keyed by the record hash and both are invalidated at the same moments, so RecordHash → (record, version) gives ADR 001's basis check and this ADR's resolution in a single lookup.

-

Structural and data staleness separate cleanly, which is better than the regenerate-on-demand shape would have given:

-
incoming surrogatemeansanswer
does not resolvethe record is gone — a category or item it named was removedreject; there is nothing to write to
resolves, version matchesthe client's basis is currentapply
resolves, version differsthe record changed since the client read itreject as stale (ADR 001), offer force
-

Regenerating from the layout would have conflated the first two rows, because a record scrolled out of the viewport would have looked identical to a record that no longer exists.

-

Whether a forged or stale surrogate is an error or a rejection. Under the regenerate-from-layout shape it is simply unresolvable, which suggests the same Applied rejection path ADR 001 defines rather than a distinct error.

-

#Consequences

-

Good. Unblocks improvise-1ey. Keeps the client thin. Makes an invalid SetCell unconstructible rather than rejected. Composes with ADR 001 instead of duplicating it. Projects a distinction the model already makes, so there is one source of truth for what is settable.

-

Bad. CellDisplay grows a field, and it is on the hot path — every visible cell carries it, so the surrogate's width is a wire-size decision, not only a collision one. A client holding a surrogate across a layout change holds something that will fail; that is correct, but the UI must handle it as a normal outcome rather than an error (the same obligation ADR 001 already places on rejection).

-

Not addressed. The other composability blockers improvise-q08 listed — ToggleGroup, AddItemInGroup, RemoveFormula, LoadModel — which are separate projection gaps and are not solved by a cell surrogate.

-

#Extension (2026-09-20) — two more kinds, by the same reasoning

-

The decision above is unchanged. CellId has since grown two variants, both arrived at by applying clause 1 rather than departing from it: a cell whose permission differs gets a different kind of identity, so the wrong operation stays unconstructible instead of being refused at runtime.

-
pub enum CellId {
-    Record(RecordCell),
-    Aggregate(AggregateHash),
-    /// A cell a formula owns (improvise-ir7l).
-    Derived { target: String },
-    /// A records-mode creating column (improvise-poia).
-    Create(CreateKind),
-}
-

Derived — a formula target is a real stored coordinate, so the original two-variant enum called it a Record and every client offered an editor over it. The write landed, and was then shadowed forever by formula_cache. It carries the formula's target and no surrogate: there is nothing a client could spend on a SetCell, for the same reason Aggregate carries an AggregateHash.

-

Create — the records-mode + and +cat columns projected None, which this ADR defines as "no projected cell". That conflation is the one clause 1's rejected Option<CellId> draft was about, arriving from the other side: not an aggregate mistaken for an absence, but a column the user can type into mistaken for one. It carries only the kind, because what the user types into a creating column is a name, not a value — the operation is ModelCommand::AddItem / AddCategory, which already existed, so no new command was needed and the record under the cursor is not part of it.

-

What the original two clauses cost, and still cost, is unchanged: a client learns no coordinate space, and an invalid operation is unconstructible rather than rejected. What the extension adds is that the enum is now four answers to "what may be done here", so None finally means only what this ADR always said it meant — no projected cell at all.

-
-
- - diff --git a/site/docs/adr/003-live-backed-models.html b/site/docs/adr/003-live-backed-models.html deleted file mode 100644 index dcc68601..00000000 --- a/site/docs/adr/003-live-backed-models.html +++ /dev/null @@ -1,51 +0,0 @@ - - - - - -ADR 003 — Live-backed models — improvise docs - - - -
- -
-

Source: context/adr/003-live-backed-models.md

On this page

ADR 003 — A model may be backed by a live query source

-

Status: accepted, not a priority (2026-09-19) · Decided by: the user · Supersedes: the read-only framing in improvise-rucf

-

The goal is to have the option of essentially using improvise as a DB client. Direction, not schedule: nothing here outranks the browser MVP (improvise-1ey), and the tickets are filed low. This is written down now so the option stays open — several decisions being made this week could foreclose it by accident.

-

#Context

-

improvise must handle arbitrary SQLite databases as models — a user points it at a database they already have and pivots over it.

-

Two readings were offered and the larger one was chosen. Import-once — read a query into a Model in RAM, exactly as CSV import does — is days of work behind an existing seam (csv_parser produces Vec<Value>, analyze_records classifies it). It was rejected because it makes the model a snapshot: source changes are invisible until re-import, and the data must fit in memory.

-

Live-backed was chosen: the model is a view over the source, and queries go to it as the user pivots.

-

This is not improvise-9ix. That ticket proposed SQLite as improvise's own persistence format and was declined on measurements — a second implementation of the pest grammar, 22% larger than .improv and 4.7× gzipped. Reading someone else's database has no round-trip obligation and no grammar duplication. What does carry over is the dependency constraint: improvise-io is in the wasm bundle, and libsqlite3-sys does not build for wasm32-unknown-unknown under this repo's nix cc-wrapper.

-

#What the model is today

-

Model holds categories, a DataStore, formulas, and views. DataStore interns every category and item name into a SymbolTable, stores IndexMap<InternedKey, CellValue>, and maintains a secondary index (Symbol, Symbol) → {InternedKey} for hot-path lookups. A Workbook loads entire — there is no partial-load path anywhere in the tree, a fact verified while declining improvise-9ix.

-

So this decision changes what Model is, not merely where its bytes come from.

-

#Decision

-

Model becomes a facade over a data source, of which the current in-RAM DataStore is one implementation and a SQL query engine is another.

-

Three things make this more tractable than it first looks, and they should shape the design rather than be discovered late.

-

1. Aggregation is already the interface, and it is already SQL. apply_agg computes Sum | Avg | Min | Max | Count over a matching cell set — those are exactly SQL's aggregates. The pushdown is not a translation layer to be invented; it is the operation the model already performs, expressed in the source's own language. GridLayout asks for a cell's value, not for the rows behind it.

-

2. Formula results are already separate from stored data. Evaluation is fixed-point (recompute_formulas iterates to stability, gotcha 9), and formula-derived values live in Model::formula_cache, not in DataStore. So the split is clean: raw data is queried, derived data stays in RAM. Formulas do not need to become SQL.

-

3. Clients are already insulated. The projection architecture means a client sees CellDisplays and never learns where a value came from — the server holds the App and therefore the connection. Live-backing and the browser MVP compose rather than compete, and a browser never needs SQLite at all.

-

#The consequence nobody asked for, and it is the important one

-

A live-backed document splits into our configuration and their data, and .improv does not currently distinguish them. Today a .improv file holds categories, views, formulas and a ## Data section. A live-backed model's categories and cell values are the source's; the views, formulas and axis assignments are improvise's.

-

So the format grows a source specification where a live model would otherwise have data — a connection, a query, and the column-to-field mapping. Categories become derived (SELECT DISTINCT) rather than declared. That is a grammar change, and improv.pest is the single source of truth, so it comes first (design-principles §3).

-

Read through the DB-client framing, that split stops being a complication and becomes the natural shape of the artifact: a live-backed .improv is a saved query plus a pivot configuration — which is precisely what a saved report is in any BI tool. The document stops being "my data" and becomes "my question", which is portable between databases in a way a data file never is, and shareable with someone who has the same source and not the same rows.

-

#Open questions, in the order they will bite

-

Writes — DECIDED: a live-backed model is read-only. Not a limitation accepted reluctantly: the user's framing is that for many use cases editing capabilities would be a downside. Pointing a pivot tool at a production database and being unable to write to it is a feature. So there is no write-through, no overlay, and no reconciliation.

-

Two things fall out, and both are simplifications rather than costs.

-

It dissolves the ADR 001 tension instead of requiring an amendment. ADR 001 governs concurrent edits; a read-only model has none, so the basis check simply does not apply to it. The guarantee is not voided, it is scoped to documents improvise owns — which is what it always meant. What remains is a smaller and different question: an external writer changes what clients see, so live-backed projections need a freshness story (polled, triggered, or explicitly stale-until-refreshed). That is projection freshness, not conflict resolution.

-

The machinery to express it already exists, built for something else. ADR 002 makes SetCell take a CellId::Record and nothing else, so a live-backed model that projects no Record ids makes a write unconstructible by the client — no runtime check, no error path, no "this model is read-only" dialog. The settability distinction built for aggregates carries read-only sources for free.

-

One design consequence for the source trait: writes should not be in the trait that a query source implements. If they were, a read-only source would have to fail at runtime for a method it structurally cannot serve. Keep reads (and aggregates) in the trait every source implements, and let writing be a capability only the owned in-RAM implementation has — so "read-only" is unrepresentable rather than enforced, the same discipline as CellId.

-

Interning and identity. DataStore interns names and ADR 002 hashes the coordinate tuple. Both still work — coordinates come from the query — but the RecordHash → record index that resolves a CellId becomes a cache with an invalidation story rather than a structure maintained alongside writes.

-

Which rows. A table name or a user-supplied SELECT. A query is strictly more useful (joins, filters, computed columns) and no harder to consume; a bare table name is sugar over it.

-

Type mapping. SQLite is dynamically typed per value, but columns carry declared types, which is better input than CSV's string sampling: detect_date_format currently guesses a date by pattern-matching text. Dates may be TEXT, INTEGER (epoch) or REAL (julian) — detect, do not assume. NULL is absence, not the string "null" (improvise-ych).

-

Where the boundary goes. improvise-core must not gain an IO dependency (forbidden(improvise_core, io)), so a query-backed source cannot simply be a DataStore variant inside core. The trait belongs in core and the SQLite implementation in improvise-io, feature-gated and native-only so the wasm gate keeps passing.

-

#Consequences

-

Good. Data larger than memory. A source that stays current. Aggregation pushed to an engine built for it. And the client/server split already built means none of this reaches a browser client.

-

Bad. It changes Model, which is the centre of the codebase — GridLayout, evaluation, persistence and the import pipeline all touch it. Latency enters a path that is currently a HashMap lookup, so the viewport-bounded projection stops being an optimisation and becomes load-bearing. And the .improv grammar grows a concept.

-

Worth doing first regardless of the eventual shape: the source trait and its in-RAM implementation, so that today's DataStore becomes an implementation rather than the implementation. That is a refactor with no behaviour change, characterization_survives_the_move applies, and it is the seam everything else needs — including, if it is ever wanted, the import-once path this ADR rejected as the only answer.

-
-
- - diff --git a/site/docs/code/clients.html b/site/docs/code/clients.html deleted file mode 100644 index 1189f97d..00000000 --- a/site/docs/code/clients.html +++ /dev/null @@ -1,362 +0,0 @@ - - - - - -The clients — improvise docs - - - -
- -
-

Source: context/code/clients.md

On this page

Clients: the browser client, the two hosts, and the harness

-

The arguments that used to live in the comment headers of improvise-wasm-client, improvise-ws-server, improvise-worker-server, web/ and harness/. Written for a reader with the code open. Bug narratives keyed by test name are in context/test-evidence/clients.md.

-

Repo-map gotchas 42, 44, 45, 46, 47 and 48 cover the same ground from the "where" side; this file is the "why", at the density the comments had.

-
-

#improvise-worker-server/src/worker.rs — the message loop

-

Worker is the whole browser host minus the two things a browser has to supply — self.onmessage/self.postMessage, and OPFS. Both are in crate::bindings, which is cfg(target_arch = "wasm32") and has no decisions in it. Everything that could be wrong about what the main thread is told is in worker.rs, where cargo test can drive it.

-

#It is the socket's Host, not a second one

-

Host is App + Sessions + handle(id, ClientFrame) -> Vec<Addressed>. Nothing in it is a socket, and improvise-9x6 is what made that load-bearing rather than tidy: the worker needs the same session loop and cannot depend on improvise-ws-server, which is tokio and tungstenite. So Host moved down to improvise_command::ui::host and both deployments name the one type. Re-implementing it here would have been a mirror, and improvise-xks6 had just finished deleting the last one: a second copy of a decision layer drifts, and the drift is invisible until a client sees it.

-

What the worker adds on top is only what a MessagePort differs in:

-
    -
  • One client, by construction. A MessagePort is point to point, so the SessionId is not an address the transport has to carry — it is a field. Addressed is still honoured rather than assumed away (Worker::frames_for), because a SharedWorker would make it matter again and the honest filter costs one line. Host::relay addresses a broadcast to every session it knows, and with one session that is always this one — a loop that assumed it away would be silently wrong at that point rather than obviously incomplete.
  • -
  • A handshake with no close. ClientFrame::Viewport must come first, for the reason it must on the socket: a Snapshot seeds the visible cells, so one built at extent zero is an empty grid. A socket enforces that by closing the connection; a worker has nothing to close, so a frame that arrives before the handshake is dropped, which is the same outcome without a mechanism that does not exist here.
  • -
-

#ClientFrame::Key is answered with nothing, and that is not laziness

-

A worker holds the authoritative App, so ui::reduce::reduce_key really is callable here where it is not on a socket. Calling it would still be wrong, and the reason is not about transports at all: nothing in the projection stream carries the host's own ViewState. A client's view slice moves only by folding Commands, and a keypress resolved against the App moves the App's slice — its mode, its cursor, its minibuffer — with no Command produced that any client could fold. ClientSession::project emits document projections, a grid diff and a basis; none of the three is a mode change.

-

So a half-wired Key arm does not under-deliver, it diverges: the host would believe it is in AppMode::Editing while the client draws Normal, and ClientSession::viewport() reads the mirror's cursor, so the grid would not even scroll to follow.

-

improvise-bpq2 answered it, and not by deriving Commands from a ViewState delta. The user's ruling is that a keymap is UI and the server must not know it, so resolution moves to the client: a key the client can answer arrives here already resolved, as a ClientFrame::Commands batch it has folded itself, and this arm's emptiness stops being a gap and becomes the statement. What remains upstream is the set of bindings a client still cannot compose — prefix keys, anything naming a row's category or a group, anything touching Storage — and those are closed by enriching the projection, one at a time, not by wiring this arm up. The list lives in keys.rs (see below).

-

#Persistence is the worker's, whether or not anybody asks

-

There is no ModelCommand::Save, and that is not an oversight: effect::Save is EffectKind::ViewOnly (it writes the document to this host's file path and clears this host's dirty flag; it mutates no document state), so the derivation that produces ModelCommand from the model effects cannot reach it (decision(improvise-cqi, command_set_derived_not_invented)). Saving was therefore unreachable from any client on any transport until improvise-368h added ClientFrame::Save — a request to the host, framed rather than commanded precisely because it mutates nothing shared.

-

A browser still never sends it, and that is the right answer rather than a gap: there is no file the user owns and no Ctrl-S semantics to honour, so asking would be the wrong question — the document is the OPFS entry, and a page that loses an edit because nobody pressed a key has simply lost it. So the worker persists on its own behalf: Worker::persist runs the one existing Save effect through App::apply_effects, so effects remain the only mutation site and the save path is not duplicated. A worker with no file_path silently does nothing, which is what Save already does; a failed save leaves its message in the status and aborts the batch.

-

The trigger is an observation of the document, not a table of command variants. It used to be the latter, and the reason was honest: a wire ModelCommand::SetCell reached effect::SetCell, which did not raise model_state.dirty — that was a separate MarkDirty effect only the key path composed — so "save if dirty" was a trigger that never fired (improvise-gv9r). With dirtiness raised by App::apply_effects off the same EffectKind the Dirty is, the flag is the observation, and asking it is better than enumerating variants for the same reason the cursor reset watches cursor_epoch rather than matching on CycleAxis, and the rebase watches document_generation rather than matching on LoadModel: a table of variants is a second description of which commands change the document, free to disagree with the first. It is also strictly tighter — a refused batch changes nothing, so it raises nothing, and the "was anything rejected" special case that used to guard this went with it.

-

One cost remains and is stated rather than hidden: Storage is whole-document by construction, so OPFS rewrites the file per accepted model command — correct, not yet cheap. And a failed write leaves the flag raised, so the next frame retries: right for a transient failure, and harmless for a permanent one (a worker with no file_path) because the only thing a failed Save produces is a status line, and nothing in a worker renders one.

-

The one frame on_frame does not auto-persist after is ClientFrame::Save: a client that asked to save has already been answered, and a failed write leaves dirty set — so retrying would repeat the I/O and, on a transient failure, tell the client SaveFailed about a document that is now on disk. The automatic write exists for edits nobody asked to save.

-

So this is the browser's own answer, and ClientFrame::Save is the other one — for a socket client editing a TUI-hosted document, where there is a file somebody owns.

-

#Ordering

-

Worker::on_frame returns the frames for one inbound frame, in order, and a ServerFrame::Commands batch must reach the client whole and in sequence: a GridUpdate::Cells that overtakes the labels framing it is dropped, not replayed. That is a property of the fold, so a postMessage transport owes it exactly as a socket does — never coalesce two batches, never split one.

-

#Malformed frames are loud

-

Worker::on_message returns Err on a frame that will not parse rather than swallowing it. A worker that silently ignores a malformed frame leaves the client waiting forever for an answer that is never coming.

-

Worker is not Debug, for the reason Host is not: it owns the whole document.

-
-

#improvise-ws-server — the socket host

-

Three layers, and the split is the point — the frames are a fourth thing this crate speaks but does not own:

-
    -
  • the frames are not here. ClientFrame/ServerFrame are improvise-protocol's, because the same envelopes frame the worker deployment and a browser bundle cannot depend on this crate (improvise-xks6). This crate speaks them and owns none of them.
  • -
  • host — the session loop: one authoritative App, one Sessions, and a handle(id, msg) -> Vec<Addressed> that is where every decision is made and every test is pointed. Not here either, and for the same reason the frames are not: the browser worker hosts the same loop over a MessagePort and cannot depend on tokio. src/host.rs is a re-export of improvise_command::ui::host, kept rather than deleted so improvise_ws_server::host::Host still resolves. Host knowing nothing about sockets was always true — the file used to say "a websocket is awkward to test, so nothing that decides anything lives in one" — and improvise-9x6 made it load-bearing. A second copy is a mirror, and a mirror drifts; that is the same answer improvise-xks6 gave for the frames one ticket earlier.
  • -
  • registry — the documents: a map of Hosts keyed by document id, the lifecycle rules, and the one place a per-document SessionId becomes a server-wide address. Socket-free. It stayed on the server because a worker hosts exactly one document.
  • -
  • transport — tokio and tungstenite: framing, JSON, the handshake rule, and reading the requested document off the URL path. No decisions about what a client is told.
  • -
-

Native-only: tokio's networking does not build for wasm32-unknown-unknown, and it should not.

-

#The registry's grain

-

Sessions editing independent documents are trivially non-conflicting, which is why ADR 001's basis tiers — document_generation, layout_version, record_versions — live on the App and scope per document for free. A registry that let two documents share any of that would throw the free scoping away, so the registry owns no state of its own beyond the map: there is nothing here for two documents to share.

-

A SessionId is not unique server-wide. Sessions mints ids from a counter it owns, so every document's first client is SessionId(0). The server-wide address is therefore the pair, ClientId, and Registry::route is the one place a Host's SessionId acquires the document it came out of. That is the whole cross-document isolation argument, and it is structural rather than checked: a Host can only name its own sessions, and the only tag route can apply is the document whose Host produced the frames. In particular an Applied::Rebased fans a fresh Snapshot out to every client of that document and cannot reach further, because the ids it returns are meaningless anywhere else.

-

#Lifecycle

-

Open happens at startup, from the documents the host names. A client cannot ask the server to open a path it was not told about, and that is not an oversight: deciding which paths a client may open is access control, which improvise-j31x puts explicitly out of scope. Serving only what was named leaves the question unanswered rather than answering it badly. A stem collision is fatal rather than resolved: two files with the same stem are two documents a client could not tell apart, and silently serving one is worse than saying so.

-

Close does not happen when the last session leaves. A document stays resident for the life of the server. Evicting it was considered and rejected twice over:

-
    -
  • Unsaved edits would vanish. The server has no autosave obligation, so a reconnect would silently roll the document back to what is on disk.
  • -
  • It would rewind the basis tiers. document_generation and record_versions start at zero in a fresh App, so a client that reconnected holding a stale basis would have it accepted as current — a correctness hole in the exact mechanism ADR 001 is building, paid for with a memory saving nobody asked for.
  • -
-

An idle document therefore costs its App, and Registry::is_idle exists so a future eviction policy has somewhere to ask the question from — nothing calls it to evict, because nothing evicts.

-

#The transport's two rules

-

A client names its document in the URL (improvise-j31x): ws://host:port/quarterly asks for quarterly, and ws://host:port/ asks for whichever one there is when there is exactly one. The path was chosen over a new ClientFrame variant for two reasons: a document is a resource, which is what a URL path is for, and ClientFrame stays exactly as it was, so every client written against the single-document server keeps working unchanged. Which document a name resolves to is Registry::resolve's decision, not the transport's; all that happens there is path -> Option<&str>. It is not a path walker — everything after the leading slashes is the name.

-

The other rule is that the first frame of a connection reports the extent, because the bootstrap Snapshot cannot be built without it.

-

A refused connection is closed with a websocket close frame carrying the reason. No ServerFrame::Error was added: the frame vocabulary is derived from the mutations that need it, and "this connection is not happening" is a fact about the connection, which the protocol already has a frame for.

-

A Registry is not Send (a Host's App owns a Box<dyn Storage>), and must not be shared anyway: it holds the authoritative documents, and one owner is the point. So it sits in a single task on a LocalSet and every connection talks to it over a channel. That is also what serializes the clients — commands arrive in one order, and the projections each client gets are produced in that order, which matters because a GridUpdate::Cells that overtakes its labels is dropped. One task for the whole registry rather than one per document is a throughput choice and nothing more: the runtime is single-threaded either way, and no state is shared between documents by sharing a queue.

-

No auth, no TLS, localhost only. A deliberate MVP limit (improvise-q08), not an oversight: anyone who can open a socket to this port can rewrite every document it serves, so it binds a loopback address by default and serve says so. --bind exists so the limit is a choice you can see rather than one buried in the code. ADR 001 owns what multi-writer safety becomes.

-

#Origin

-

src/origin.rs is the handshake's one access control, and the reason it cannot be delegated: a websocket handshake is exempt from the same-origin policy. There is no CORS preflight, so any page a user visits can open a socket to the endpoint from that user's own address, and read and edit every document the server holds — cross-site websocket hijacking (improvise-kc7n). An address check cannot see it, because the request genuinely comes from an allowed address; an IP allow-list, a private network and loopback binding all answer "may this client reach the endpoint", and the attacker's answer is honestly yes. Only Origin says what is driving the client, which is doctrine(who_may_connect_is_two_questions). Loopback is the least protected case, not the safest: there is no proxy in front of it to check anything, and loopback is not a boundary a browser enforces, so while the server runs any page open in that browser can reach ws://127.0.0.1:8787.

-

The default allows loopback pages and nothing else, because a default that fails open is worth nothing and a default that breaks the shipped recipe is a different bug. The two shipped paths were the constraint. The local pairing recipe serves the page with python3 -m http.server 8080 --directory web, so its origin is http://localhost:8080 while the socket is on another loopback port — loopback is therefore the default, not a flag, or the one case worth optimising for would need configuring. The deployed Pages page sends https://fiddlerwoaroof.github.io and can only reach the server through a TLS proxy (improvise-ws-server speaks plain ws://), which is already a multi-step setup with an Origin allow-list in it; one --allow-origin is therefore an honest cost there and the project's own deployment origin is not hard-coded as a default. OriginPolicy::permits is the whole decision, and the transport's admitted is the only caller.

-

An absent Origin is allowed, decided rather than defaulted. Every browser sends one on a handshake, unconditionally — unlike fetch, there is no no-Origin mode to fall into — so an absent one is a non-browser client: curl, a native client, this crate's own tests/socket.rs. Refusing those would break the repo's own tests and every scripted client while buying nothing, since an attacker who can run a non-browser client already has direct network access, which is the question --bind and a proxy answer. The cost is stated rather than hidden: a proxy that strips Origin turns this check off, so the proxy's own Origin allow-list stays in the recipe as defence in depth.

-

Two shapes state no origin a policy can answer for, and both are refused rather than read as absent: an Origin that is not valid text, and two Origin headers. HeaderMap::get is first-wins, and the deployment this README recommends puts a proxy in front of the socket — one that appends rather than sets the header leaves two, and answering from the first lets the second be anything at all. No browser sends two.

-

What this does not buy is authentication. It answers "what page is driving this client", never "who is connecting"; anything that is not a browser page sends no Origin and is unaffected by any of it. improvise-kpnr is still open and is a different question.

-
-

#improvise-protocol/src/resolve.rs — one keypress → Commands

-

#It is not the browser's module any more (improvise-0yq8)

-

resolve/step and ClientState were improvise-wasm-client's, and nothing about either was a browser fact: both depend on ViewState, the protocol widget caches and the announced window, every one of which is already a protocol type. Being in the wasm crate meant a second front end could reach them only by depending on a cdylib built for a browser, so the move cost no new dependency and bought the rule design-principles §5 states — a new front end needs exactly two things: its own event → KeyEvent mapping, and painting. BrowserKey is the first of those two and is the only thing left in improvise-wasm-client/src/keys.rs; ClientState is now improvise_protocol::client, and typed_cell_command went with step, which calls it.

-

What did not become trivial is tests/lockstep.rs. It drives improvise_command::ui::reduce::reduce_key — the host's Cmd/Effect path — against resolve, and those are still two derivations of the same answer, in two crates, one of which cannot be reached from a browser. Moving resolve changed which crate it is spelled in, not that it is a mirror, so doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) does not fire and the test stays.

-

#The keymap is not this module's

-

It used to be. improvise-bpq2 gave this file a hand-written classifier that decided, per key and per mode, what a keypress meant — a second spelling of default_keymaps(), kept in step by a lockstep test over a hand-written list. The user's ruling is that the web front end uses the same keymap as the TUI, not a mirror of it, and doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says why: two definitions held honest by a compatibility test are one definition waiting to be made.

-

So the table moved to improvise_protocol::keymap and this module reads it. Which key means what is now correct by construction: a rebinding in default_keymaps() reaches the browser with no change here, and a new binding for a command this module can already execute simply works.

-

#What is left here, and why it is the better boundary

-

A lookup yields a command name and arguments. Turning that into something that runs needs a CmdContext — a Model, a GridLayout, an App — which this client does not have. So key → intent is shared and intent → Command is ours, for the subset we can compose. step is that subset, and it is the list that replaces the old list of keys. That is a list of names rather than of keys, which is strictly better in two ways: it is enumerable, and it does not go stale when a binding moves.

-

What this client can execute, and what each one needed:

-
    -
  • Text entry (append-char, pop-char, clear-buffer). The buffer is the binding's own argument now — no longer read off AppMode::minibuffer() and hoped to agree with what the host's keymap named. TextEntry::new derives one from the other, so the two were equal; they are now the same string. The pair read_buffer/write_buffer mirrors cmd::core's, which are the only code on the host that knows a runtime-named buffer may not be an entry in buffers at all (improvise-gdl): the name "search" is ViewState::search_query. The shared keymap binds append-char ["search"] in SearchMode, so this client needs the same pair — and having it is what lets search typing resolve here for the first time.
  • -
  • Mode entry (enter-mode), decoded through AppMode::from_name, which moved next to AppMode for the same reason the keymap did.
  • -
  • Grid, panel and tile cursors, which needed GridExtent and the window this client announced.
  • -
  • Entering an edit (edit-or-drill), which needed ADR 002: the branch EditOrDrill takes on "is this cell aggregated" is exactly the CellId variant the server already projects.
  • -
  • Committing one (commit-cell-edit, commit-and-advance-right), which needed the record surrogate — again ADR 002 — and nothing else for the plain-grid case.
  • -
-

#The second tranche, and the three it could not reach (improvise-e24v)

-

Fifteen more command names resolve here: cycle-panel-focus; the category panel's cycle-axis-at-cursor, open-item-add-at-cursor, filter-to-item, toggle-cat-expand and delete-category-at-cursor; the view panel's switch-view-at-cursor, create-and-switch-view and delete-view-at-cursor; tile select's set-axis-for-tile and cycle-axis-for-tile; the help overlay's help-page-next/help-page-prev; commit-buffer in all three of its kinds; and the two cell writes clear-cell and paste.

-

Each is Cmd-for-Cmd, including the parts that look like noise: the status sentence a command writes beside its mutation is part of the answer, and a client that emitted the mutation alone would diverge on the status line and nowhere else — which is precisely the sort of divergence nobody notices. Two orderings are load-bearing and copied rather than rationalised. CommitBuffer writes its optimistic status first so the add's own failure message wins (improvise-6t3), and TileAxisOp writes the axis first and the status after.

-

Three of the tranche's own list turned out to need enrichment after all (improvise-4bwa), and the reasons are worth more than the code:

-
    -
  • toggle-prune-empty words its status off View::prune_empty, which is in the Workbook, in no cache, and not in ViewState.
  • -
  • delete-formula-at-cursor needs (target, target_category); FormulaCache carries the formula's raw text, and recovering the two from it means reimplementing parse_formula on the client.
  • -
  • view-back emits ApplyAndClearDrill before ViewBack, and that effect is deliberately off the wire (gotcha 38): its operand is somebody's uncommitted drill edits. This one is not an enrichment at all — it is blocked on ADR 001 layer 2's addressed form, and mirroring it by rebuilding the effect on the client would re-open exactly what improvise-3x3u closed.
  • -
-

The lockstep harness these are tested through (both_ends in tests/lockstep.rs) is worth two sentences, because two of its choices were wrong first. It compares the document as .improv text, not as Debug: Debug prints the symbol table's HashMaps, so two byte-identical documents print differently per process (gotcha 18), and the first version failed on nothing but hash order. And it applies the client's composed commands to a second authoritative host rather than eyeballing them, restating the basis against that host with caught_up — the claim was published by the first host, and the basis is the only thing that legitimately differs between two hosts holding the same document.

-

How the client claims, and why there is nothing here to keep in step. A client must answer the same question the server does — which records does this ModelCommand write — to build its Basis. The first version of this answered it again, in two helpers (structural, claiming nothing, and record_write, claiming the cell's own record), and the adversarial review of PR #50 showed that nothing tested them: lockstep.rs's apply_on restates the basis against the twin with caught_up, which re-derives it with the server's records_named and throws the client's claim away before anything is compared. Emptying record_write's claim left the workspace green.

-

The fix is not a test. records_named is pure — &ModelCommand -> Vec<RecordHash>, no App — and RecordHash is an improvise-core type the protocol already re-exports, so it moved down into improvise-protocol beside the enum it reads, and improvise-command re-exports it. resolve.rs has one model() that claims records_named(&cmd). The client's claim is now right by construction, and the guard is the one that was always there: records_named is an exhaustive match, so a new record-writing variant fails the build.

-

What the old shape would have cost is worth recording, because it is not a compile error: structural accepted any variant and claimed [], so a new record-writing command would have compiled clean on the client with the wrong answer, and the symptom is Rejection::RecordUnstated — the browser binding becomes a dead key, refused, with nothing failing.

-

both_ends compares every cursor, not only the document, the mode and the status. Three of the resolvers' decisions are a cursor move and nothing else — delete_view_at_cursor's step-back is the sharpest — so a comparison that stopped at the document would agree while the panel cursor diverged.

-

#Mirror the Cmd line for line, including where siblings disagree

-

doctrine(mirror_the_cmd_line_for_line). Move emits SetSelected even when it clamps and MovePanelCursor returns nothing when the cursor does not move; MoveTileCursor emits regardless, like Move. Tidying that into one rule would make this a second derivation, which is exactly what tests/lockstep.rs exists to rule out. Which widgets say nothing about a move that goes nowhere is not a rule of this module — it is whatever the Cmd being mirrored does, and the three differ.

-

Two more places the mirroring is literal. panel_cursor clamps a cursor past the end before applying the delta, and derives the category panel's length from flatten_cat_tree — which depends on expanded_cats, per-client, which is why it is derived rather than sent and why the derivation lives in improvise-protocol where both ends reach it (improvise-edp). And commit mirrors change_mode(exit_mode_for(edit_mode)), then EnterAdvance — which aborts the rest of the batch at the bottom-right corner, so the re-entry is skipped and the user lands in normal mode — then EnterEditAtCursor, which seeds the edit buffer from the cell it landed on. The trailing clear-buffer the keymap welds to every commit then empties that buffer again. That ordering looks wrong and is exactly what the TUI does; mirroring it is the point.

-

GridMove is a hand-spelled mirror of navigation.rs's MoveKind, because the command crate is not on a browser's dependency list and never will be. The keys that reach it are no longer spelled twice. scrolled_to mirrors navigation.rs::viewport_effects over the window this client announced.

-

#The three outcomes

-

KeyOutcome::Local(Vec<Command>) — resolved here; the caller folds them locally and sends them, as one frame and in this order (the asker's own fold is its own; the host forwards only to the others). A Vec because one keypress is one binding but several Effects: a cursor move is SetSelected plus whichever scroll offsets follow, and a Binding::Sequence is several commands to begin with. Never empty — a keypress this client can answer with nothing is Ignored, and KeyOutcome::of is the one constructor so that contract is a property of the type's users rather than a rule each resolver remembers.

-

Upstream(KeyEvent) — bound to a command this client cannot compose. Ignored — nothing bound, or what is bound resolves to no change. Nothing bound is not "the host might know better": the host looks the key up in this same table. That is new; it used to go upstream into an empty vec.

-

A Binding::Sequence is all or nothing: half of Esc — clearing the buffer without leaving the mode — is worse than none of it. Each step is resolved against the unchanged state, because the host resolves every step against one immutable CmdContext; the commands are then folded in order.

-

#What still needs the host

-

the_command_names_that_still_need_the_host_are_these names them, and Host::handle answers a Key frame with an empty vec, so each one is a binding that does nothing at all in a browser (doctrine(an_upstream_key_is_a_black_hole)).

-

An upstream key that the app also ADVERTISES is a different severity, and it had its own class of defect (improvise-rmvx). A key nobody offers doing nothing is a gap; a key named in the mode's own hint line doing nothing reads as broken. The two are crossed by every_advertised_command_answers_in_its_own _mode, which asks help::advertised — a table of command names, so the cross-reference is exact and nothing tokenises the rendered string — against resolve in every mode, over a fixture furnished enough that an Upstream can only mean an unhandled name. The old guard asked only about Normal, which is why add-record-row and delete-formula-at-cursor were outside its reach entirely; view-back in RecordsNormal was a seventh instance nobody had noticed.

-

Ignored is a black hole too, and the guard watches both variants. KeyOutcome::of turning an empty Local into an honest Ignored is good design and also a laundering step — every wrong answer that composes nothing arrives wearing the variant that is usually innocent. A guard's discriminator must therefore be state (did anything the user can see change) and never the shape of the return value. Watching only Upstream let three of the six declines go completely silent with 1397 Rust, 19 harness and 26 node tests green, because no browser spec presses those three keys (PR #71 review).

-

nothing_declined_is_also_composed closes the other direction: step reaches declined first, so an arm in composed for a declined name is unreachable, and the browser would go on saying "not available yet" over working code. It is derived from declined itself over every bound (name, args) in every mode, so a seventh decline is covered without editing it. enter-mode was a real overlap on its first run; rather than move the check, both halves hold the rule — composed returns None for a stranded mode — so removing one fails towards Upstream rather than towards a dead room.

-

declined is the answer, and it is one list rather than an arm apiece: six command names, each with its own true subject. Wording matters here more than it looks — the > fix was nearly routed through a message about aggregates, which would have been true-sounding about the wrong thing and worse than silence, because a reader who is told something plausible stops looking. step checks declined first, so composed has no arm for any of them and the two lists cannot overlap.

-

A declining step takes its whole sequence with it: o in RecordsNormal is [add-record-row, enter-edit-at-cursor], and the second step's premise is false once the first has declined. Returning only the decline, rather than the decline plus whatever composed before it, is what keeps Binding::Sequence's "every step or none of them" true — an abort is not a rollback (gotcha 21), and this client has no way to undo a Command it has already put on the wire.

-

The classes that remain:

-
    -
  • Prefix keys. Command::SetTransientPrefix exists and this client could arm one; what it cannot do is what the second key then runs, which is yank, wq and toggle-col-group-under-cursor. Arming a prefix whose payload is unreachable is worse than not arming it.
  • -
  • Anything naming a row, column, item or group — hide-selected-row-item, toggle-group-under-cursor, delete-formula-at-cursor. The first two are no longer blocked and are simply not built here yet (improvise-e24v): since improvise-anr9 GridCache carries an AxisFrame per axis, so cats[0] + label(idx, 0) is exactly what HideSelectedRowItem reads off GridLayout, and group_of(idx) is exactly ToggleGroup's (category, group). The one thing to get right is that the frame is windowed and the cursor is absolute, so a client must reconcile the scroll offset first, as render.rs does for the selection. delete-formula-at-cursor stays: it names a position in the formula panel, not in the grid.
  • -
  • Anything touching storage or an arbitrary command line. The host's by definition.
  • -
  • The records-mode commit — except the two creating columns. A wide-records row is one coordinate tuple and editing a category column moves every measure cell in it; commit_plain_records_edit needs GridLayout::record_col to know which. A CellId::Create does not: it commits the typed NAME as one existing ModelCommand (AddItem into _Measure, or AddCategory), reads no record_col and writes no cell, so improvise-gcvb narrowed the early return rather than removing it. The +cat half must not advance: an inserted column renumbers every index to its right and the advance was computed from the pre-commit layout (gotcha 23), so CommitAndAdvance returns straight after it and a client that advanced anyway would land one column off, silently. AddCategory can also be refused by the 12-category limit and sets abort_effects; the mode change therefore goes first, where an abort cannot reach it (gotcha 21).
  • -
  • Drill. Session-local residue, and the one case that does not go upstream silently — see below.
  • -
-

#The three improvise-e24v listed as free, and what each really costs

-

improvise-4bwa. Each of the three is short exactly one fact, and the three answers are different in kind — which is the point, because "needs enrichment" read as one bucket is what let them be listed as free.

-

view-back is declined, permanently as things stand, and it is not an enrichment at all. ViewNavigate { forward: false } emits ApplyAndClearDrill before ViewBack, and ApplyAndClearDrill is deliberately off the wire: its operand is drill_state.pending_edits, session- local residue belonging to whoever staged it, and a Command::Model is routed with no SessionId (gotcha 38, improvise-3x3u). A client that mirrored view-back by reconstructing the drill commit would commit somebody else's uncommitted typing — which is the exact thing 3x3u removed, and the reason it must not be "mirrored line for line" here despite that being the rule everywhere else in this module. So it stays upstream until ADR 001 layer 2 gives the addressed form. Note the sharper reading: mirror_the_cmd_line_for_line is a rule about resolvers, and a Cmd whose first effect is unaddressable is not a Cmd a thin client may mirror at all. The doctrine does not conflict with this; it simply does not reach it.

-

toggle-prune-empty needs one bool on a per-view projection. TogglePruneEmpty::execute reads ctx.view.prune_empty only to word its status — "Showing all rows/columns" against "Hiding empty rows/columns". The ModelCommand itself is composable today; it is the sentence beside it that a client cannot write, so mirroring it would put the two ends out of step on the status line in exactly half of all presses. View::prune_empty is on the View inside the Workbook, in no widget cache and not in ViewState.

-

delete-formula-at-cursor needs an element-type change, and the shape of the change is the whole decision. The effect is RemoveFormula { target, target_category }; FormulaCache carries the formula's raw text ("Profit = Revenue - Cost"), from which neither field is recoverable without reimplementing parse_formula in the client. Adding a targets vector beside formulas would be two answers to "what is in the formula panel", which is improvise-edp's disease and the mistake improvise-anr9 had to pay a type change to undo. So the element type grows the fields; it does not acquire a parallel array.

-

Both of those are ordinary projection additions and both live in improvise-protocol (render.rs, resolve.rs) and improvise-command (ui/project.rs) — not in this crate, which is why this section records the design and the work lands with whoever owns those files. Whatever lands, the named list the_command_names_that_still_need_the_host_are_these shrinks by exactly what closed.

-

#What a client that could parse would cost: measured

-

improvise-cl7y asks a client to stop claiming "Formula added" for text only the host can parse. Its own note says the two routes are a dependency decision or a status addressed back to the composer, and leaves the cost of the first unmeasured. It is measured now, by adding improvise-formula to this crate, calling parse_formula from an exported method so nothing is stripped, and building the bundle both ways:

-
improvise_wasm_client_bg.wasmgzipped
today503,854191,613
with improvise-formula634,515238,973
cost+130,661 (+25.9%)+47,360 (+24.7%)
-

The raw column is exact and byte-verified; the gzipped one is a gzip -9 proxy and not what a visitor downloads, since GitHub Pages serves brotli. The conclusion is unaffected — the raw figure alone carries it — but do not quote "+47,360 gzipped" as a transfer size.

-

A quarter of the download, for a status string. That is the evidence for the other route — the host addressing a status back to the composer, the way ClientFrame::Save's rejection already does — and it costs no dependency.

-

Note what the measurement does not settle, because it is the more interesting half: a client that could parse would be a second derivation of whether a formula is well formed, agreeing with the host only for as long as both call the same function. The status line is not the only thing that wants it, so the number above is a floor rather than the price of a feature.

-

#stranding: a prompt this client can enter but cannot commit

-

enter-mode is trivially resolvable here, and that is the trap: the user reaches the prompt, types a path, presses Enter — and the commit resolves to nothing, because Host::handle answers a Key frame with an empty vec. A dead room is worse than a locked door: the door costs one inert key, the room swallows work the user believes was done.

-

SavePrompt and ExportPrompt are host-only by construction, not by omission: effect::Save, SaveAs and ExportCsv are all EffectKind::ViewOnly, so the derivation that builds ModelCommand from the model effects cannot name them. Closing this is not a resolver to add; it is a wire operation that does not exist. In a browser it is also close to meaningless: the worker persists on its own behalf whenever the document is dirty, so the document is the OPFS entry and there is no Ctrl-S semantics to offer.

-

It returns the subject rather than a bool (improvise-rmvx): the refusal is right and the silence was not, so : now says the command line is unavailable instead of doing nothing at all. The predicate is named as a property rather than matched at the call site (doctrine(two_questions_one_enum)): "can this client finish what entering the mode starts" is not the same question as "is this a text-entry mode", and a third such prompt belongs here. Found by Copilot on PR #27 against the newly added SavePrompt; ExportPrompt had the identical trap already and was fixed with it — fixing one arm of a rule with two is the shape doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names.

-

CommandMode is dead by omission, and listed anyway. Copilot found it on the next round, and it was a false negative in the predicate's first version — which mattered more than its severity, because a_prompt_this_client_cannot_commit_is_never_entered used : as the negative arm that stops "refuse every mode" from passing. An arm asserting something false does not merely fail to guard; it certifies the behaviour it was added to rule out. : resolves locally into CommandMode and typing appends locally, but Enter is [execute-command, clear-buffer] and step has no execute-command arm, so resolve returns Upstream and the frame is dropped. Every ex-command behind : is inert for the same reason, so refusing the mode removes no working surface, only a room that swallows typing.

-

The distinction from the two prompts is worth keeping visible, because it says who removes that line. They are dead by construction: no ModelCommand can ever name Save/SaveAs/ExportCsv, so no resolver would help. CommandMode is dead by omission: execute-command is implementable client-side and improvise-e24v is scheduled to do it. Listing it is therefore also the named gap gotcha 47 asks for — closing e24v has to come to that line and delete it.

-

#Per-arm notes that would otherwise be re-derived wrongly

-
    -
  • enter-tile-select. EnterTileSelect gates on model.category_names().len() > 0, which counts the virtual categories too — _Index, _Dim and _Measure always exist — so the gate is invariantly true and there is nothing here to mirror it against. Re-deriving it from CategoryTreeCache, which carries the panel's rows rather than category_names(), would be a second derivation that disagrees on exactly the empty document.
  • -
  • transpose. It used to be emitted alongside a mark_dirty this client had no wire variant for; since improvise-gv9r dirtiness is raised by the host off the effect's own EffectKind, so there is nothing to mirror and nothing missing. The cursor reset is not this client's to send either: the effect calls App::reset_cursor, and adopt_cursor_epoch broadcasts Command::ResetCursor to every client on a view the batch reshaped (improvise-nynk) — so sending one here would be a second, differently-addressed copy.
  • -
  • toggle-panel-and-focus. The interactive arm of TogglePanelAndFocus: open-and-focus unless this panel is already both, in which case close and unfocus. The decision is made in registry.rs on the host and here on the client, from the same two ViewState facts.
  • -
  • edit-or-drill. The host asks whether a regular category sits on Axis::None and whether the cursor is on a synthetic records cell. This client asks neither: the server already published the answer as the CellId variant, and doctrine(a_demo_is_checked_against_the_type_not_the_axes) says to read the type rather than re-derive the rule. It reads edit_text, never text: the drawn text has been through format_f64, so a stored 1200 reads "1,200" under a comma-grouping format and committing it would store the string "1,200". tests/lockstep.rs found exactly that.
  • -
  • Drill does not go upstream — by either door. Every Upstream is a black hole, and a key that visibly does nothing is the worst of the three answers. Drill is session-local residue and reaching it needs a CellKey this client cannot build, so the honest answer is to say so: a SetStatus, which is a view-half command — Sessions::apply folds it into the asker's own mirror and 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.
  • -
  • 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" means.
  • -
  • cell_at_cursor returns None when the selection is scrolled out of the window — not a state the cursor keys can produce, since they scroll to follow, but one a projection race can.
  • -
  • grid_move's two empty shapes are kept apart from "not ours": an empty grid has nowhere to move to, and a client that has not announced a viewport has been sent no grid either. Neither is a key the host could answer better.
  • -
  • Quit has no keymap, on purpose: ModeKey::from_app_mode is exhaustive so that a new mode without bindings is a compile error rather than a mode that swallows every key.
  • -
-

#Browser key → improvise key

-

to_key_event returns None for two cases the host must never see. A bare modifier press ("Shift", "Control", …) is not a keypress. And anything held with the platform meta key is left to the browser: KeyModifiers has no bit for Meta, so forwarding Cmd-R would arrive as a plain r and type into a cell instead of reloading the page.

-

Two conversions are not obvious. A terminal reports Shift-Tab as its own code and the keymaps bind that code; the browser reports Tab plus a shift flag. And shift is deliberately dropped for character keys: the browser already applied it ("A", not shift+"a"), and the shared keymap's lookup_local retries character keys without modifiers for that very reason, so keeping the flag would only make an exact binding miss.

-

#The pointer: Click, and the four rules it obeys

-

A click is resolved here for the same reason a keypress is — a front end paints and wires, it does not decide (design-principles §5). resolve::click takes a Click naming a widget and an index, and returns the Commands. web/app.js attaches listeners and knows nothing else; there is one entry point per widget on Client, which was the user's ruling on improvise-7ybz.

-

Rule 1 — a click navigates to what was clicked, and activates it where activation is something the client can express. Every arm composes the same shape: focus the panel, SetPanelCursor to the row, then whatever Enter does on that row. The activation is resolved against the clicked index, never against the cursor, because the SetPanelCursor ahead of it has not been folded when the resolver runs — the trap that reads as "the cursor lands on Plan and the document switches to Default", silent and wrong in the half a user does not watch.

-

The second clause of rule 1 is what makes the formula panel look inconsistent and is in fact the rule holding. ModeKey::ViewPanel binds Enter to switch-view-at-cursor; ModeKey::FormulaPanel binds a/n/o and d/Delete and no Enter at all. So a view-row click switches and a formula-row click only navigates. Opening the editor on a formula click would be an activation invented for the pointer that the keyboard does not have — a second derivation of what a formula row means, which is the disease §5 names. The guard for this must be aimed at the keymap, not at the click: asserting that the click emits no FormulaEdit only restates the author's reading, and a reviewer demonstrated the gap by adding Enter -> enter-mode formula-edit to FormulaPanel and watching the whole workspace stay green while the click and the keyboard disagreed. the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does asserts the binding's absence and names click_formula_row as what must change if it ever appears.

-

The browser assertion that PR #60 dropped stays dropped, and improvise-qf57 is the decision rather than the oversight. #grid input toHaveCount(0) was argued to be redundant with the #mode assertion on the grounds that toHaveText("FORMULAS") is exact and AppMode::FormulaEdit labels as "FORMULA" — a coincidence of two strings differing by one character, which would be a thin reason to leave it out. Measuring it gives a better one: it is not redundant, it is unreachable. web/app.js creates a grid <input> in exactly one place, the grid cell's own mousedown, gated on cell.editable; no repaint creates one and no panel handler can reach it. And an invented formula-row activation opens the minibuffer, not a grid editor. Injecting Command::ChangeMode(AppMode::formula_edit()) into click_formula_row and running the spec: #grid input toHaveCount(0) passes, and the run fails on the #mode assertion below it. Restoring it would add an assertion that cannot fail — the same defect this file already records one line down, where an explanatory assert! was removed for never being able to be the failing one.

-

Rule 2 — a click is all of its intents or none of them (improvise-hkr8). Each click_* returns Option<Vec<Command>> and click does the single unwrap_or_default. The case that matters is an activation the client cannot compose: filter_to_item_at goes through model, which goes through BasisCache::claim, which returns None until the server has published a basis (gotcha 43). Before the fix the None was swallowed per-intent, so the click kept its navigation prefix, dropped its activation, and reported success — while the keyboard handled the identical None correctly by answering KeyOutcome::Upstream. Note that None at the click level means something different and benign: the whole click composed nothing, which is the invariant rather than a breach of it.

-

Rule 3 — a click on an unselected tile never mutates the document. The user's ruling on the tile bar. The cost accepted is two clicks for the common action and a deliberate divergence from the keyboard, where Enter cycles directly — record it as chosen, not as an inconsistency to tidy, or someone will read the binding and "fix" the pointer to match. What it buys is worth more than the keystroke: a tile cycle is a LayoutGeometry change, which bumps cursor_epoch and therefore resets every connected client's cursor (gotcha 24), so a stray click used to cost every other viewer their position.

-

State the rule this precisely, because the looser wordings are false. The implemented rule is "clicking the already-selected tile cycles it; clicking any other selects it", where already selected is mode == TileSelect and tile_cat_idx == index. The tempting summaries — "a click never mutates on its own", "a second click cycles" — both have the same counterexample, found on review: T enters TileSelect leaving tile_cat_idx at 0, so a click on the leftmost tile is a click on the already-selected one and cycles on the first gesture. Two adjacent tiles therefore answer the identical first click differently, and that is correct rather than a hole — a click does what Enter does there, and Enter in TileSelect cycles the tile under the cursor. The resolver cannot see how the state arose and must not try to.

-

The mode conjunct is still load-bearing in the other direction: without it, tile_cat_idx == 0 in Normal mode would read tile 0 as already selected, and the very first click of a session on the leftmost tile would cycle it.

-

Rule 4 — the shared helper is the idiom, and breaking it is how the two arms drift. click_category_row calls filter_to_item_at, click_tile calls tile_axis_at, click_view_row calls switch_view_at — each is foo_at(index) with the keyboard's foo_at_cursor calling foo_at(cursor). click_view_row originally re-spelled SwitchView + ChangeMode(Normal) inline, so a host-side change to SwitchViewAtCursor was forced into the keyboard arm by tests/lockstep.rs and left the click behind silently. A helper that returns only the name rather than the whole activation is what invites that.

-

#The category panel opens at launch, in both front ends

-

ViewState::default() sets category_panel_open: true. The user ruled this twice: first that the sidebar be visible at launch, then — asked whether the terminal was collateral — that it applies to both. improvise model.improv opening with the sidebar showing is intended, not a side effect of sharing a default. It is the shared default for the reason wsaw gives: a browser-only default would be "the page overrides the flag", which is the same defect as "the page ignores the flag" wearing different clothes.

-

A launch default disarms every test that asserted the thing it now supplies, and that is the expensive part. Three tests asserted category_panel_open was true after doing something to make it true; all three then passed without the code under them working. The worst was set_panel_open_and_cursor: with it vacuous, effect::SetPanelOpen's Category arm could be replaced by a no-op — C doing nothing in the terminal — with the whole workspace green. The rule this leaves: assert the direction the default cannot supply. Closing the panel is the discriminating move; opening it proves nothing.

-

Note the two mappings that had to be repaired separately. effect::SetPanelOpen and reduce_view's Command::SetPanelOpen are two three-way matches in two crates — the TUI folds one and the browser the other — so a guard on either says nothing about the other. That is a derivation done twice (§5), pre-existing and filed rather than fixed here.

-

Two tests, because either alone passes against the bug wsaw fixed: the panel is visible with no keypress, and C still hides it. Injecting "the page ignores the flag" fails the second; injecting "the default is false" fails the first. A test that only asserts the sidebar is visible cannot tell the flag being honoured from the flag being ignored.

-
-

#improvise-wasm-client — the crate, the fold, the render model

-

#What the crate is, structurally

-

One wasm32 artifact, reused literally in both deployments: the thin client talking to a server over a WebSocket and the standalone app talking to a worker that hosts the App over the worker's own port. Only the JS bootstrap differs — nothing in the crate knows what the transport is.

-

It is a fold and a key classifier, and nothing else. It holds a ViewState plus one widget cache each — there is no aggregate render cache — and it moves them by folding the same Command stream the host folds. The fold is state.rs; the key classifier is keys.rs.

-

The argument that shaped it: a keymap binding names an intent resolved against a CmdContext; a Command is the resolved mutation. A thin client holds no Model and no GridLayout, so it cannot do that resolution. (Since improvise-bpq2 the table is shared and the resolution of the composable subset happens here; what is left upstream is the subset keys.rs names.)

-

That is why the command crate is a dev-dependency and not a dependency: its keymap could not be used here even if the bundle could afford App, Workbook, persistence and the import wizard. What the dev-dependency buys is tests/lockstep.rs, which drives the authoritative host and this client over the same keypress and asserts the two ViewStates agree — the correspondence is checked rather than asserted in prose.

-

The boundary is JSON, so the JS side needs no generated structural bindings and the same strings go on a socket unchanged. No std::fs (it compiles for wasm and fails in a browser) and no clock: nothing here calls Instant::now(), so the web-time shim the command crate needs is not needed. Both are structural, not disciplined — this crate depends only on improvise-protocol, serde, serde_json and wasm-bindgen, none of which reaches a filesystem or a clock.

-

Ordering. The JS bootstrap must deliver frames to Client::on_frame in arrival order, one at a time. The grid fold is deliberately not commutative — a GridUpdate::Cells that overtakes the labels framing it is dropped — so batching or reordering silently loses cells. For the same reason a locally-resolved command and a forwarded keypress must go out on the same ordered channel, in the order Client::on_key_event returned them.

-

Why the wasm_bindgen surface is a thin wrapper over a plain-Rust one. wasm_bindgen methods cannot take or return the protocol's types, so there is one code path serialized at the edge. The split is not only ergonomic: JsError::new panics off wasm, so every fallible path has to be reachable without it or its failure arm cannot be tested at all.

-

Client::viewport vs Client::set_viewport. viewport is associated rather than a method, because a page must send the handshake before it has anything to fold. But it builds the frame and forgets it, so a client driven only through it never learns its own window and therefore resolves no cursor key locally — a truthful subset (those keys go upstream and the host answers nothing) but a whole feature quietly off. set_viewport is the one a page should call. The window is not derivable from anything the host sends: the row frame's own length is min(window, grid − offset), so it agrees with the window except near the bottom edge, which is precisely where the scroll arithmetic matters. viewport is kept because web/app.js already spells it and retiring it is a co-ordinated change.

-

#improvise-protocol/src/client.rs — the two properties this dispatch owes

-

Both are tested in state.rs rather than assumed from the protocol crate's own tests, because the dispatch between them is this crate's.

-
    -
  • The stream is not reordered. Every fold path is a for over arrival order; there is no batching, sorting or coalescing.
  • -
  • Bootstrap is a compaction, not a replay. ClientState::bootstrap folds Snapshot::seed exactly as the steady stream is folded, so there is no second code path that could disagree with the incremental one. GridCache::seed puts the labels before the cells for the same ordering reason.
  • -
-

window is deliberately not reset by bootstrap: the browser window did not change size because the document was replaced, and clearing it would silently stop this client resolving its own cursor keys until the next resize.

-

last_rejection is kept as a value, not a sentence. A rejection is the input to force, and force is a re-composition against the republished basis — which needs to know which record and which kind. RecordGone cannot be forced at all; RecordMoved can be forced with one keystroke. A string cannot carry that. It is session-local: it belongs to whoever composed the refused batch, so it is not part of the portable slice and does not survive a bootstrap.

-

window is session-local and not portable for the same family of reasons: it is a fact about a browser window, which is why no Command carries it and why it is not on ViewState. (0, 0) means this client has not announced one yet, and a client in that state resolves no cursor key — it has been sent no grid either.

-

#socket.rs — the frames, the wording, and the one composition

-

Where the frames live: not here. ClientFrame/ServerFrame are improvise-protocol's, and the same pair frames both deployments. This crate used to carry two envelope pairs of its own: one mirroring improvise-ws-server, because a native crate cannot be a dependency of a wasm bundle, and one written for the worker before the projection architecture landed. The mirror was held honest by two tests round-tripping every variant through the server's own types; with one definition there is nothing left for them to compare, which is what improvise-xks6 was for.

-

rejection_message is spelled here rather than on Rejection for the reason AxisColor is not a ratatui colour: the protocol carries the fact, and each front end words it. The wording tracks ADR 001's force table, because "try again" and "this cannot be forced" are the two different things a user must be able to tell apart — a RecordGone has nothing to write to, and offering force there would promise something the design refuses to deliver (force never creates structure). Two wordings are load-bearing: DocumentReplaced must not say "reloading", because nothing here reloads, and the two save failures are not staleness so neither offers a retry — retrying changes nothing about the host's storage.

-

typed is a rule that genuinely lives in two places. It is the same three-way split commit_regular_cell_value makes — empty clears, a parseable number is a number, anything else is text. It is duplicated here for the reason the whole client exists: the browser has no App to ask, and ModelCommand::SetCell takes a CellValue, so somebody on this side must decide. Putting it in Rust rather than in the page's JavaScript is what makes it testable and keeps one spelling per language boundary instead of two.

-

This is the only answer the client gives to "what does a typed string mean" (improvise-r4lo), and it holds on every deployment including the worker: a host's App sits behind the frame boundary and is handed a value, not a string, so no host ever classifies a string that arrived over the wire. The careful half: a host does classify its own local keystrokes, which is why this is a duplicate of that split rather than a call to it — the command crate is deliberately not a dependency, and reaching it would drag App, Workbook and persistence into the browser bundle.

-

So a_typed_string_is_classified_as_the_host_classifies_it states the obligation without discharging it: it asserts the three outcomes by hand rather than comparing with the host's function, so the two could drift and stay green. That is doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) still open, one layer below what improvise-r4lo closed — the real fix is a shared split in improvise-protocol, which both crates already depend on. Filed rather than done.

-

typed's None means "clear this cell", which is a different command, not a different value — hence the Option rather than a CellValue::Text("").

-

typed_cell_frame is the composition improvise-q08 found a thin client could not do. It holds no Model, so it cannot build a CellKey — but it does not need to: the id on the cell it is already drawing is the address, and it is echoed back unread (ADR 002). It takes the user's string, not a CellValue, for the reason above; there is no deployment for which a pre-classified entry point would be right. A sibling taking a serialized CellValue used to sit beside it and claim the opposite; it was deleted rather than re-documented, because it could not express ClearCell and was therefore a strictly weaker duplicate.

-

None on four shapes, each a different fact the client already holds: the position is outside the grid; the position holds no settable cell (id: None — padding, or a records-mode column that renames a coordinate rather than writing one); the cell is an aggregate, which is the pattern failing to bind rather than a check; or this client has not been told the document's basis and so has nothing truthful to say about what it composed against (BasisCache::claim withholds rather than invents).

-

Nothing is folded there. The new value comes back as a GridUpdate::Cells from the host, which is the side that formats it; guessing it locally is how the two ends drift apart.

-

typed_cell_command is the one composition shared by the mouse path (typed_cell_frame) and the keyboard path (keys::commit). Writing it twice is how a browser commit by Enter and a browser commit by click would come to disagree about what an empty string means.

-

#render.rs — the four decisions that are silent when wrong

-

The DOM half of this client is JavaScript. The split is drawn to make that acceptable rather than comfortable: every question with a wrong answer is answered here, in Rust, under test, and what crosses to the page is a list of rows of cells that each already know whether they are editable, numeric, erroneous and selected. The page's job is document.createElement.

-
    -
  • Editable is a fact about the id, not about the text. Only CellId::Record may be written (ADR 002), and there are now four kinds, each naming a permission. CellId::Aggregate is a drill target and Drill is not on the wire yet, so it renders as non-editable rather than as a control that does nothing. CellId::Derived { target } is a cell a formula owns (improvise-ir7l): i on one names the formula rather than opening an editor, because a write there lands and is then shadowed forever by formula_cache. CellId::Create(kind) is a records-mode +/+cat column (improvise-poia), where what the user types is a name, not a value. And None is not any of those — it is "no projected cell here": a padded slot, a blanked one, or a records-mode column that renames a coordinate across the whole row rather than writing one.
  • -
  • The selection is absolute and the grid is not. GridCache is indexed by position within the viewport, while selected and row_offset/col_offset are positions within the whole grid. Reconciling the offset is the entire conversion, and the trap is that forgetting it looks right at the top of a document and wrong after the first scroll. render.rs does it by adding — r + cursor.row_offset == sel_row — so there is no underflow path and a selection above the window simply never matches.
  • -
  • The category panel is flatten_cat_tree, not a second walk of the cache. That function exists in improvise-protocol precisely so this end and move-panel-cursor share one flattening (improvise-edp); the page never sees CategoryTreeCache at all.
  • -
  • The grid's frame is AxisFrame, and every header decision is made here (improvise-anr9). The multi-level header levels, the repeat suppression, the group runs and their collapse state come off the same AxisFrame methods the TUI widget calls, so the page receives labels already blanked where they repeat and group runs already spanned. Until this landed the protocol carried GridLayout::row_label's slash-joined tuple and the page drew Food/Alice where the TUI drew two header columns — a derivation the TUI's own test says is wrong, shipped to the other front end.
  • -
-

Nothing in RenderModel is on the wire. It is a projection of a projection — the client's own caches shaped for one renderer — so it may change freely without a protocol conversation.

-

Four smaller decisions that would be re-derived wrongly:

-
    -
  • RenderRow is an enum, not a label plus an is_group flag, so a group header cannot carry cells and a data row cannot carry a collapse state. It is serialized with a kind tag because the consumer is JavaScript. labels() is empty for a group header, which carries its label in its own variant rather than in a one-element tuple — so a caller walking labels() over the whole model sees the data rows and nothing else, which is exactly what neither_front_end_produces_a_slash_joined_label wants. A caller that means "every label on screen" has to match on the variant.
  • -
  • RenderColGroup::label is an Option, not an empty string. An empty string is a legal group name — Category::add_item_in_group accepts one, and the TUI draws its indicator like any other — so a sentinel would serialize that group identically to an ungrouped run and the page would drop its indicator (raised by Copilot on PR #23). None for an ungrouped run also keeps the runs a partition of the columns: the spans sum to the column count, so a page laying them out as colspan cannot leave a hole.
  • -
  • Column group runs coalesce by NAME alone, which is what src/ui/grid.rs does (group_name != prev_group), and the two must agree. Collapse is category-scoped, so two adjacent groups from different categories may legally share a name and differ in it; keying on the pair would start a second run the TUI suppresses. The run's indicator is its FIRST group's, again as the TUI paints it. col_groups returns nothing at all when no column is in a group, because "this grid has no column groups" and "every column's group is blank" are different statements and the page draws a line for only one of them — the same asymmetry GridCache::totals has.
  • -
  • hint and editing are decided here, not in the page. hint is AppMode::hint_text(), the same string the TUI's footer draws, so the page spells no keys of its own — which is what keeps a second front end from growing its own idea of the bindings, and makes the modes this client can enter but not yet draw (Help above all) state what they are instead of looking like a broken grid. editing is AppMode::is_editing() plus the edit buffer: without it, a browser that resolved i locally would change its mode indicator and then swallow every keystroke invisibly.
  • -
  • minibuffer is the same argument, for the other six text-entry modes (improvise-fdt6). editing drives the inline cell editor, so it is scoped to the two cell-editing modes and reads the "edit" buffer by name — which left FormulaEdit, CategoryAdd, ItemAdd, SavePrompt, ExportPrompt and CommandMode opening a prompt and swallowing every character typed into it. minibuffer is AppMode::minibuffer() plus the buffer that config names, read with improvise_protocol::read_buffer so the "search" special case keeps its one home (gotcha 11); None is "no text-entry mode is open", so the page asks nothing about the mode. The colour stays semantic — MinibufColor crosses and the CSS class is the page's last translation, exactly as draw.rs::minibuf_fg is the TUI's (gotcha 26). It is drawn as a line of its own rather than in place of #status as the TUI's bottom bar does, because #status also carries the transport's failures and a repaint must not erase one (gotcha 48).
  • -
-

RenderCell::edit_text is the stored value, not the formatted one: seeding an editor from text and committing turns 1200 into the string "1,200". The numeric/error flags are the server's answers, echoed — the client must not re-derive them from the text, which is how the two ends drift.

-

render_model builds header_labels(0) once for the whole frame, because the per-position accessors scan and asking them inside the row loop is quadratic.

-
-

#render.rs and the help overlay (improvise-ktv1)

-

RenderModel carries help, help_page and help_titles, and the decision the page must not make is whether the overlay is open: an empty help is what says it is closed. That is the shape col_groups already uses for "this grid has no column groups" — an absent list rather than a flag the page has to interpret alongside a mode it would then have to understand.

-

The rows themselves are improvise_protocol::help::pages(), the same ones src/ui/help.rs draws. RenderHelpRow carries a kind string (heading, keys, accent, …) which is a semantic name the page maps to a CSS class — the AxisColor → axis_fg shape again, and the reason the page can style a heading without knowing what makes a line a heading. Where the content comes from and what is derived from the keymap is context/code/protocol.md §8.

-

#web/app.js — the page

-

The browser half of improvise-cr3, kept as thin as it can be made, now talking to either of two peers (improvise-djm). The boundary is JSON strings in and out, so this file holds no protocol knowledge at all: it never inspects a Command, never builds a CellId, never decides whether a cell is editable or whether a typed string is a number. Five calls are the whole harness:

-
callwhat it is
client.set_viewport(rows, cols)the handshake frame, and every resize; also records the window so keys resolve here
client.on_frame(json)fold one frame, in arrival order
client.render_model()everything to paint, already decided
client.typed_cell(r, c, s)the frame for "the user typed s here"
client.on_key_event(k,c,a,s,m)the frame for a keypress, folded locally first where this client may answer it
-

The two rules this file must not break are ordering and framing: frames are folded one at a time in the order they arrive (a cell update that overtakes its labels is dropped, not replayed), and the first frame out must be the viewport, because the server builds the snapshot at it.

-

Everything in this file that is a decision rather than a paint now lives in web/shell.js — socketUrl, the #status composition, the watchdog, the extent arithmetic and gridPlan — so that node --test can reach it without a browser. The sections below describe those rules; the code is one file over.

-

#Two transports, one client

-

The wasm bundle is the SAME artifact either way (improvise-xks6: one envelope pair, two deployments). Only where the strings come from differs, so the difference is confined to openTransport and nothing below it knows which peer it has:

-
    -
  • default — an in-page Web Worker hosting the whole App over OPFS (improvise-9x6). No server process; this is what makes a GitHub Pages copy of the directory a working application.
  • -
  • ?ws=host:port — a websocket to improvise-ws-server. That process is how a document opened in the TUI is shared, so the path is kept; the presence of the parameter is the whole selection mechanism, because a second knob naming the same fact is a second thing to get wrong.
  • -
-

Neither is reachable silently. Every transport writes what it is doing to #status — "starting", "connected", "cannot reach …", "disconnected" — so a peer that never arrives reads differently from one that went away. "disconnected" means exactly the second: a socket that opened and then closed. A socket that never opened keeps saying "cannot reach …", because the close event there is the tail of the failure and not news.

-

#status carries two independent facts — what the transport is doing and what the document last said — so it composes them rather than letting a repaint quietly erase a connection error.

-

#socketUrl: wss:// is the default

-

A bare host:port gets wss://, because the address that works from where this page is actually served is the one it should be easiest to write. The page ships to GitHub Pages, which is https, and an https origin refuses a ws:// address as mixed content — so a ws:// default would hand the browser a URL that cannot work and report it as a connection failure, which reads like "the server is not running".

-

An explicit ws:// is honoured only where it can function: a page served over plain http, which is the local python3 -m http.server case that pairs with improvise-ws-server (which speaks plain ws:// only — it has no TLS, so reaching it from https means terminating TLS in front of it). From an https page an explicit ws:// is upgraded rather than passed through, since passing it through can only produce a failure the user cannot act on.

-

#set_viewport, and the twin that is gone

-

Same frame, but it also records the window. A client learns its window only from what it announced — the row frame's own length is the window clipped to the grid, so the two differ exactly at the bottom edge, which is where the scroll arithmetic matters. Without it the client cannot clamp, so every cursor key goes upstream into Host::handle's empty vec and the arrow keys silently do nothing (improvise-bpq2).

-

There used to be a frame-only twin, Client::viewport, exported beside it and called by nobody. Its doc said it was kept because web/app.js already spelled it — and web/app.js called set_viewport in the same wave, so the reason was false before it was read (improvise-seik). That matters more than an ordinary unused function, because the two are one letter apart and the wrong one fails silently: it sends an identical frame and simply forgets the window, which turns every cursor key into the black hole above. It is deleted, along with the exported init() free function that duplicated new Client().

-

A deletion is not the guard, though — the next export with no caller arrives the same way. web/tests/bundle/wasm_surface.test.js now reads the generated .d.ts and requires every declared method to be one app.js calls, or to be named in an UNCALLED table with its reason. free and initSync are wasm-bindgen's own; revision is there because on_frame returns it. Proved by injecting a spare static viewport(...) declaration into the built .d.ts and watching it fail by name.

-

#frameCost: what the grid's own frame costs, in whole cells

-

One header row and one header column was right until improvise-anr9: the column header is now one row PER LEVEL plus an optional group row, the row header is row_levels columns wide, and inline group headers take a row each. Reserving one of each announces a viewport the page cannot show, and #grid has overflow: auto, so the DOM would acquire a scroll position of its own while the client's cursor offsets were computed from the larger window — putting the selected cell off screen.

-

It is measured off the painted model rather than predicted, and defaults to the old one-and-one before the first paint, which is the only moment there is nothing to measure.

-

Inline group ROWS are deliberately NOT reserved. They take real height, but how many are in view depends on how many rows the window asked for, so every way of counting them is either a feedback loop (count the current ones: a smaller window crosses fewer groups and asks for a larger one) or a ratchet (keep the maximum: a group-dense grid stores an oversized cost on its first oversized projection and never lets it fall — one group header per row clamps the grid to a single row, permanently). Both were tried. What is left is an over-request bounded by the groups in view: a grouped grid asks for a few rows more than the table shows, which matters only for a selection near the bottom edge — and that gap is not new and not specific to groups, since nothing in this page calls scrollIntoView at all. Filed as its own ticket rather than answered with a heuristic in a file the harness only partly executes.

-

#The watchdog, and the two close arms

-

The handshake watchdog is about the first frame, not about either peer's own failure modes. The socket has an error event for a peer that never answers; the worker has none that fires, because worker.js catches its own start() rejection — so a browser with no OPFS, or a worker bundle that failed to load, would leave the page saying "in-page worker" over an empty grid forever. "The peer never answered the handshake" is the one condition that means the same thing in both deployments, and a check aimed at each peer's own failure modes would have missed exactly this one.

-

A peer that failed loudly has already said something more precise, so the watchdog must be DISARMED and not merely pre-empted: it fires later and would overwrite the better message with "did not answer" (PR #13 review). The watchdog is for silence only, and setShellFailure clears the timer.

-

Did this socket ever open? The page has to record that itself, because nothing on the close event carries it: readyState is already CLOSED by the time a close listener runs, whichever happened, and a refused connection and a mid-session network drop are BOTH code 1006 with wasClean: false. So "never connected" and "was connected and went away" are distinguishable only by remembering — an opened flag is the only discriminator.

-

A socket that never opened did not "disconnect": it failed to connect, and the close event is the tail of that failure rather than a second, vaguer thing that happened (improvise-taxx). Writing "disconnected" there unconditionally replaced the error listener's precise message with the least informative of the two facts.

-

That is the same family as the watchdog bug and NOT the same fix. The watchdog is disarmed because it is for silence only, whereas suppressing close outright would hide a genuine mid-session disconnect, which is the one thing that listener is really for. Hence the split rather than a guard. Both arms go through setShellFailure, which disarms the watchdog — and that half is the one that keeps getting lost. It matters on the opened path: the watchdog is armed after transport.ready resolves, i.e. on open, so a socket that opened and then dropped before its first frame reaches the close listener with the timer still pending, and "disconnected" is more precise than the guess it would otherwise fire 8s later. On the never-opened path there is no timer yet and the clear is a no-op. Neither arm leaves one armed.

-

#Painting

-
    -
  • The column-group line is drawn only when there is one: an empty col_groups means this grid has no column groups at all, not that every group is blank. label is null for a run in no group, and an empty STRING is a legal group name that still gets its indicator — which is why the page does a null check and not a truthiness test.
  • -
  • One header line per level, and a blank entry is repeat suppression decided in Rust, never an absent label.
  • -
  • The cell coordinates a click sends are DATA row indices, and a group header is not a data row, so the page counts rows rather than trusting the position in model.rows.
  • -
  • r is one binding shared by every closure in the row loop, so a click handler that captured it would fire with the LAST row's index. It is snapshotted.
  • -
  • A keyboard edit is open when model.editing is a string, and it holds what has been typed so far. Both facts are decided in Rust — the page does not know which modes are editing modes or which buffer they type into. Without it the mode indicator would say INSERT while every keystroke vanished into a buffer nobody drew.
  • -
  • editable is the server's answer relayed through Rust: a stored cell. An aggregate is a drill target and drill is not on the wire yet, so it is simply not offered.
  • -
  • The inline editor seeds from RenderCell::edit_text — the STORED value, not the drawn one: td.textContent has been through the number format, so a stored 1200 reads "1,200" and committing that stores the string "1,200". Enter removes the focused <input>, which fires blur, which calls close again: harmless, but only by accident.
  • -
  • The title bar is three fields and one joining. doc_name, doc_dirty and doc_stored_as come off the cache src/draw.rs::draw_title folds; the joining — name (stored_as) [+], in that order — is shell.js::documentTitle, which is the TUI's own spelling minus the improvise · chrome and the right-hand key strip. The three were projected by improvise-z754 and painted by nothing for a wave, and render_surface.test.js could not see it: that check is a substring search, so a field named by a function nothing calls reads as painted. What sees it is contract.spec.js, which loads the page and asserts the text (improvise-5jna). The marker itself is a node test rather than a harness one, because the standalone deployment persists on every accepted batch and clears the flag in the same round trip, so [+] is transient there.
  • -
-

#The selection scrolls itself into view

-

web/app.js called scrollIntoView nowhere at all, so a selection driven past the bottom of #grid stayed there, invisible, with no way back but scrolling by hand (improvise-jq8l). Two things about it are easy to get wrong.

-

It is not a projection bug and not a row_offset bug. The row had been sent: #grid has overflow: auto and the page announces an extent from ROW_PX/ COL_PX, which under-estimate — a real row is 28.6px against the assumed 26 — and inline group rows are deliberately not charged for at all. So the page renders more rows than its own box can show, and the overflow is the DOM's, not the window's. Measured on harness/fixtures/tall.improv: 105px of overflow, the selected cell 96px below the visible bottom, scrollTop still 0.

-

block: "nearest" is the whole option. It scrolls only when the element is actually out of view, and it scrolls the nearest scroll container rather than the document, which is what keeps the page itself from moving sideways.

-

The reason this was untestable until now is the fixture: web/demo.improv is four rows and harness/fixtures/grouped.improv is eight, and neither overflows a 484px box. tall.improv is thirty rows in three groups, and the spec asserts the overflow exists before asserting anything about the scroll — without that line the whole test passes on a page that never scrolls.

-

#The pointer takes cells on mousedown

-

A cell's handler is mousedown with preventDefault(), not click, and the reason is an ordering trap worth the paragraph (improvise-0x9u). From an editing state a click never arrived at all: the mousedown moved focus, the <input>'s blur ran close, close repainted, replaceChildren destroyed the <td> the mousedown had landed on, and no click was ever dispatched. The pointer was dead until the user pressed Escape or clicked outside the grid.

-

Three things had to move together, and each is load-bearing:

-
    -
  • mousedown, so the handler runs before any focus change.
  • -
  • preventDefault(), so no focus change happens at all. Otherwise the default action focuses #grid after the handler has called input.focus() on the new editor, and blurs it immediately. The keyboard listener is on window, so nothing needs #grid focused.
  • -
  • close no longer repaints, and blur checks closed first. Removing a focused <input> fires blur synchronously, so a close that repainted from inside itself detached the very <td> the handler was about to open an editor on. This is the same repair one level down, and it is why the first attempt at the fix still failed: the blur arm re-entered and rebuilt the table. close restores the drawn text rather than the seed, since nothing repaints behind it any more.
  • -
-

The state the old spec could not see is the whole point: it pressed Escape before clicking the second cell, so it only ever clicked from a non-editing page — this repo's characteristic defect, a test that passes while the thing it names is broken in a neighbouring state.

-

#The keyboard

-

Client::on_key_event classifies the event and folds it locally where this client may answer it — text entry, and since improvise-bpq2 the grid, panel and tile cursors too, which is why sendViewport must call set_viewport and not Client.viewport. Anything it cannot answer becomes a ClientFrame::Key, which Host::handle answers with an empty vec: those keys are sent and do nothing. That is settled rather than unfinished — the host must not know the keymap — and closing one means teaching the client, not that arm.

-

The page does not claim a key it did not answer: a frame means the page claimed it, so the browser must not also act on it — an arrow that both moves the cursor and scrolls the page is the worse of the two errors. Combinations under Ctrl or Alt are left alone regardless: those are the browser's own shortcuts, and the client resolves nothing under them. BrowserKey drops anything held with the platform meta key precisely so Cmd-R does not arrive as a bare r; the page does not claim it either.

-
-

#web/worker.js — the worker half

-

It hosts the authoritative App. Everything it decides is decided in Rust — the file only moves strings between self.onmessage/self.postMessage and WorkerServer::on_message, which is the same shape web/app.js has on the main thread and for the same reason: the less JavaScript there is, the less can be silently wrong.

-

Two rules it must not break:

-
    -
  1. Post every reply, in the order the Rust returned them, one message per frame. A GridUpdate::Cells that overtakes the labels framing it is DROPPED by the client's fold, not replayed. Never batch two calls' replies together and never reorder them.
  2. -
  3. Await flush() after each message. Storage::delete is visible to read/list immediately but only reaches OPFS there.
  4. -
-

Rule 1 is about frames within one call's replies AND about the calls themselves, which is why every message is chained onto one tail promise rather than handled by an async listener: an async listener does not serialize. The event loop is free to start the next message while an earlier one is suspended at an await, so two on_message/flush pairs interleave and a later frame's replies can be posted before an earlier frame's. The chain is also what makes "frames that arrived before start() finished" an ordinary case of the same rule instead of a second code path with its own drain loop — and that loop had the race in it twice over, since server was set before it ran.

-

The chaining itself is web/serial.js::serialize, which is promise plumbing and nothing else, so web/tests/serial.test.js can force the interleaving a browser cannot be asked to produce on demand — including the negative arm, which runs the same two handlers without the chain and shows them coming back swapped.

-

The document is named in the worker's own URL — new Worker('worker.js?doc=demo.improv') — the analogue of the socket server naming it in the ws:// path. It is deliberately not a ClientFrame: the envelope pair is shared with the socket deployment and stays exactly as it is (improvise-xks6). The seed is fetched and handed to Rust, which writes it through the VFS and then reads it back like any other document — not injected past storage. A missing seed is not an error: with none, the worker creates an empty model under DOC.

-

An unhandled rejection inside a message listener reaches the devtools console and nothing else, so it is caught there — naming which frame it was, which the main thread cannot see and has no way to ask — rather than left on the chain, where one bad frame would poison every later one.

-
-

#docs/ — the landing page and its casts

-

#The casts are recorded by a script, not by asciinema rec

-

docs/record-casts.py drives improvise on a pty it owns and writes asciicast v2 itself. asciinema rec records a shell, and the shell it records is the recorder's own: every previous cast opened and closed on (0) Mac:<hostname> ~gf/u/edwlan/improvise, on what improvise-kh8 had since made the site root, and formulas.cast and import.cast showed that prompt as their visible opening frame (improvise-2oxt).

-

The repair is structural rather than careful: improvise is the pty's only child, so there is no shell for a prompt to leak from — nothing to remember to clean, and nothing that comes back when somebody re-records on a different machine. Three other things stopped being accidents at the same time. The geometry is an input (120×37, the numbers docs/index.html already spelled, which is why they were not changed — a 30-row player over a 37-row cast clips the tile bar and the status line, exactly where the output is). The pacing is a per-step table rather than typing speed. And the header carries no env and no timestamp: the first held $SHELL, the second the recording machine's clock, and neither is anything a visitor is owed.

-

Two decisions inside the scripts that are not obvious and cost a re-record each:

-
    -
  • No :q is typed at the end. The player loops, so the last frame is also the frame before the first, and a minibuffer reading :q is what it would show. The process is signalled instead.
  • -
  • Each scenario has to be arranged so the thing it names is visible. The drill cast drilled into a cell with no records behind it — a true frame about nothing — so it now moves Customer off the page axis first, which makes the cell an aggregate over nine customers and gives the records grid more than one row. The pivot cast pressed c on a category that was already on the column axis, which is a no-op with a status line. Neither was visible from the keystrokes; both were found by rendering the recording back and reading it.
  • -
-

That last point is the method, and it is reusable: a cast can be replayed through a small ANSI reconstruction and read as text, so "what does frame N actually show" is a question with an answer rather than a guess. It is how the +cat and + columns in drill.cast — improvise-2oxt's substantive complaint, since the old recording predated both — were confirmed present.

-

What the harness can check, and does: landing.spec.js asserts over the cast files that no header carries env or timestamp, that the geometry is the one the page configures, and that no chunk contains an OSC window-title sequence, which is the leak's signature — improvise emits none and a shell emits one per prompt, carrying the cwd. Asserted over the file rather than the rendering because that is where the leak lives: a player draws a prompt as ordinary text, and a screenshot assertion would have to know what the hostname had been.

-
-

#harness/ — the browser instrument

-

#Why it exists, and what belongs in it

-

Before harness/ there was no JavaScript test infrastructure in the repository at all — no package.json, no runner, nothing in CI running a single line of web/app.js or web/worker.js — so every bug in them was found by reading and the bug-first rule could not be honoured for a change to either. PR #21 declined to add regression tests for its close-listener fix on exactly that ground, and that decline is only honest because those assertions live here instead.

-

Playwright rather than a headless-DOM runner because what goes wrong in these files is a real Worker, a real WebSocket, real OPFS and real event ordering, none of which a DOM shim has. That still holds, and it is why no shim was added; what changed is the scope — see the section below, which is now the answer to "where does a change to web/*.js belong". It is deliberately small (improvise-vvl3): the point is to be able to run storage_contract against OpfsFs and to read #status out of a real page, not to grow a second test framework beside cargo test. The server root is harness/serve, built by build.sh — the assembled site plus the harness's own page; a module script will not load from file://, so an http server is not optional.

-

#Engine coverage

-

improvise-xf1's acceptance criterion names Chrome, Firefox and Safari, and the OPFS spec is the reason: createSyncAccessHandle is the one API the whole design rests on, its sync read/write/truncate signatures were added later than the handle itself, and each engine implemented it separately. Chromium alone would be one engine's opinion.

-

Result today: Chromium and Firefox run the whole battery and pass. Playwright's WebKit provides no OPFS at all — getDirectory() itself rejects with UnknownError — so Safari is NOT covered and xf1's criterion is met for two engines of three. The project is kept rather than deleted so that it starts running the day that build gains OPFS; opfs.spec.js skips on a runtime probe, not on the browser name, so if Chromium or Firefox ever stops providing OPFS it fails rather than skipping. An unexpected skip on any other engine is a failure — a silently skipped check is exactly what this harness exists to prevent.

-

The shell spec's mocked-peer block is Chromium-only on purpose: it is about web/app.js's own logic, which is engine-independent, and routeWebSocket is slower and flakier to no benefit across three. That argument is about those four tests and was never made about the two beside them: the standalone block mocks nothing, it is the deployment improvise-bck's criterion (1) names in so many words ("Chrome and Firefox"), and the engine's OPFS is already exercised by opfs.spec.js, so the path is known to be there. It runs on Firefox too since improvise-zkkw — testMatch widened, grepInvert: /@mocked-peer/ — measured at 2.0s for the pair and 10/10 green under --repeat-each=5. The split is a tag rather than a third copy of the file, and widening the whole file and then quieting what flaked would be adopting a reason nobody gave.

-

Every other spec is Chromium-only, including the three added by improvise-plcx, 78v6 and bgtf: a fixture document, a help overlay and a landing page decide nothing per engine.

-

#harness/opfs-contract

-

A harness crate, not part of the application: it ships in no bundle and is not a workspace member. OpfsFs had been compile-checked and never run; this is the instrument that changed that (improvise-vvl3, absorbing improvise-xf1).

-

Why a worker. FileSystemFileHandle.createSyncAccessHandle() exists only in a dedicated Web Worker, and it is the whole reason Storage could stay synchronous. A check that ran on the main thread would fail for a reason that has nothing to do with OpfsFs.

-

Why a report rather than a panic. Every check runs and its outcome is reported, so a failure names which step and the rest still run. More importantly it is what lets the spec check the checker: Playwright asserts the report contains exactly the checks it expects, so a worker that never started, or a build that dropped a check, is a failure rather than a silent pass. The list of expected check names is spelled in opfs.spec.js as well as in Rust on purpose: a check deleted on one side has to be deleted on both, which is what stops the battery quietly shrinking.

-

wipe uses only the backend's own vocabulary — refresh, delete, commit_deletes — rather than reaching for removeEntry directly, so a broken delete shows up there as well as in the contract. It therefore cannot clear a zero-byte entry, because list no longer names one. That is deliberate and costs nothing: attach recreates and write fills whatever a previous run left behind, and a leftover empty entry is invisible to every assertion for the same reason it is invisible to a document picker.

-

opfs_root/make_directory exist only in the harness: OpfsFs has no directory vocabulary at all — it stores one flat entry per document — so making a directory, to check that refresh skips one, has to happen outside it.

-
-

#Where a change to web/*.js is tested

-

E2E and integration tests are rare and pin boundary contracts; unit tests assume those contracts and test between them. (User ruling, 2026-09-20.) So the question is not "can this assertion be made cheaper" but "which boundary is it about", and the browser half has seven:

-
boundarythe contractwhere it is pinned
RenderModel → DOMevery field the render model projects reaches an elementweb/tests/render_surface.test.js (statically) and harness/tests/contract.spec.js (on the screen)
JS → wasmthe calls the page makes exist, with the arity the bundle declares, and their answers are paintedweb/tests/bundle/wasm_surface.test.js (the surface) and contract.spec.js's pointer test (the wiring)
page → peerthe handshake goes out first, a frame is answered whole, a failure is named, and the page routes each failure through the call that disarms the watchdogharness/tests/shell.spec.js, harness/tests/drill.spec.js
key → screena key the mode's hint line ADVERTISES produces something the user can see, and a text-entry mode paints what is typed into itharness/tests/keys.spec.js
help::pages() → DOMthe overlay's rows are the shared content — the keys off the keymap and the words beside them — and not the right number of blank rowsharness/tests/help.spec.js
worker → OPFSstorage_contract, run against a real OpfsFsharness/tests/opfs.spec.js
AxisFrame → DOMthe grid's frame — header levels, group spans, the collapse indicator and the group row that carries no cells — is drawn as the frame says, not slash-joinedharness/tests/grouped.spec.js, over harness/fixtures/grouped.improv; web/tests/grid_plan.test.js pins the plan it draws from
the site → the appthe landing page's casts are really fetched and really play, and its front door reaches ./app/harness/tests/landing.spec.js
-

A unit test of a rule does not test the wiring that reaches it, and the sharpest case in this file is the watchdog. shell.test.js proves that createShell.fail() disarms rather than pre-empts; it cannot prove that app.js's socket close and error listeners call fail() rather than setShell(). That routing is PR #21's other half, the one gotcha 48 says keeps getting lost — and PR #61 lost it a third time by keeping the unit test and dropping the browser assertion, with 26/26 and 13/13 green. Restored, and the rule generalises: when a unit test replaces a boundary test, ask which of the two the regression would have been in. Here it is the routing, so the guard stays in Playwright.

-

The faked clock is faithful outside the window and silent inside it. shell.spec.js walks past the 8s handshake watchdog with page.clock, and the question that owes an answer is whether collapsing ten seconds into one synchronous step flattens an ordering rather than merely skipping the wait. Probed for the sharpest case — a peer that opens, stays silent past the window and is closed afterwards — the #status transcript is byte-identical under the fake clock and under a real nine-second wait: four states, same order, same text. What that does not cover is an event landing strictly inside the collapsed window, which a synchronous runFor gives no opportunity to deliver; that is a race whose outcome the page does not promise, so there is nothing to assert. State it rather than re-derive it.

-

A negative assertion must demonstrate the denied event is reachable on that exact code path — not on a sibling path, and not in the rule's own unit test. This file's worked example cost four review rounds. shell.arm runs only after await transport.ready, so on a socket that never opens the watchdog is never armed; a test denying "sent no document" there denies nothing, and passed against both the routing revert and the disarm regression. outlastTheWatchdog now asks the page timesArmed() > 0 first, which turns that review finding into a failure by name. Note ever armed and not armed now: a correct page has already disarmed by the time the disconnect test jumps — that is the property — so the obvious spelling would have denied the passing case.

-

And a constant a test must exceed has one home. HANDSHAKE_TIMEOUT_MS lived in web/app.js and again in the spec, under a comment saying a silent disagreement "would pass for the wrong reason". It did: the spec at 1000 against the page's 8000 made the suite green over the reverted routing, because the jump landed inside the window. It is exported from web/shell.js and imported by both, so the disagreement is unconstructible rather than discouraged.

-

Everything between those seams is node --test web/tests, and it may assume the boundary holds rather than re-establishing it: transport selection from ?ws=, the two facts #status composes, the watchdog's arm and disarm, the frameCost/extent arithmetic, the collapse glyph, the grid's plan, and the worker's tail-promise ordering. None of it needs a DOM, so none of it is Playwright's.

-

The seam that makes this possible is web/shell.js and web/serial.js: pure modules that return a description rather than take a DOM double. gridPlan(model) answers every question gridTable used to answer while it was creating elements — which row index a click carries when group headers are interleaved, what a group header spans, whether a null group label is a name, whether an empty edit buffer means an editor is open — and app.js is then a loop that creates elements from it. Painting stays untested by Node; the decision about what to paint stopped being untestable.

-

#Why the four widgets were one bug

-

The tile bar, the view list, the formula panel and the category sidebar ignoring category_panel_open were reported as four broken features and fixed as four. They were one failure at one boundary: a RenderModel field with no element painting it. Four per-feature specs cost four page loads and catch the fifth only if somebody writes a fifth spec; one assertion that every projected field is painted catches it by construction. That assertion is cheap enough to make twice, from different sides:

-
    -
  • render_surface.test.js reads the pub fields off improvise-wasm-client/src/render.rs and requires each one to be read by app.js or shell.js — or named in UNPAINTED with a reason. It needs no browser, no bundle and no build, so it fails in seconds. The list is not an escape hatch: it is the debt, written down, and it currently holds doc_name/doc_dirty/doc_stored_as (improvise-5jna — the fifth instance, found by writing this test).
  • -
  • contract.spec.js drives the demo document into each state that fills a widget and asserts the element is on the screen. Node cannot see that, and the moment a reduction would have to weaken it, it stays here.
  • -
-

And the static half is weaker than it reads, which is worth knowing before leaning on it. render_surface.test.js asks whether the string model.<field> occurs anywhere in the page's code — so a field mentioned by a function nothing ever calls passes. That happened on improvise-fdt6: the new minibuffer field had a paintMinibuffer reading it, and the call site and the getElementById were both missing, so the node suite went green over a page that still painted nothing. keys.spec.js failed on its first run, in Chromium, naming the hidden element. The static check is a completeness check — it tells you a field was not forgotten — and never an execution check; only harness/ is that (gotcha 48).

-

What is deliberately not in Playwright any more is what each key or click means: improvise_protocol::resolve decides that and its tests are where the meaning is pinned. One instance of "a resolved change repaints" is kept, because the repaint is the boundary; the other seven were the same instance again.

-

#Two numbers, and neither is the other

-

Measured on one machine, in one session, on a private IMPROVISE_HARNESS_PORT, with both trees clean — 1af51c9 reached by a full checkout rather than by restoring paths over a working branch (see the trap below). Two runs each; the pair is the variance.

-
suitechromiumall projectsexecutionwhole job
Playwright, before (1af51c9)2326 passed + 3 skipped36.2s / 36.3s~4m30s in CI
Playwright, after1417 passed + 3 skipped15.1s / 14.9s~4m30s in CI
Playwright, + wave 42126 passed + 3 skipped34.4s / 33.7s~4m30s in CI
node --test web/tests—260.12sseconds
-

The last row is improvise-78v6/igxp/plcx/bgtf/zkkw: seven more Chromium tests and two Firefox ones. Most of the 19 seconds is one test — the landing page's four asciinema casts are played until each terminal draws, which is the only assertion that can tell a cast from a 404 (evidence file). The Firefox pair is 2.0s of it.

-

Two columns because one number was ambiguous three times. Firefox runs opfs.spec.js and the standalone half of shell.spec.js and every other spec is Chromium-only, so a count taken off a run is five larger than the chromium count, with three more skipped (WebKit's OPFS battery). The columns must also agree: chromium + 3 is the passed total, and a row where they do not is a row built from two different trees. npx playwright test --list prints the per-project truth; quote which one, and this is the same "two numbers, not one" the Rust suite learned in design-principles.md §7.

-

The trap that made the first before figure wrong. It was taken by git checkout 1af51c9 -- harness web inside a working branch — which restores tracked files and leaves untracked ones where they are, so the branch's own contract.spec.js stayed and the "baseline" ran main's 23 tests plus three of the branch's. A baseline taken by checking paths out over your own work measures a tree that never existed. The full checkout above is the repair.

-

What gave it away was the count, not the clock — and that is the lesson. The contaminated run read 29 passed where a clean 1af51c9 reads 26, because three extra tests really were there; but it read 36.9s against the clean tree's 36.2s, because those three cost under a second between them. A wall clock absorbs a contaminated tree without a trace; an arithmetic identity does not. The same error survived three separate corrections — it reached PR #61's body as "26 chromium" (23 + the 3), survived a fix that corrected the chromium column and left the run count beside it, and was caught each time only by someone checking that the columns add up.

-

Where the saving actually came from, since the obvious answer is wrong. The eleven per-widget tests that were deleted cost 2.7 seconds of test time between them — restored verbatim and re-measured — against 1.55s for the three contract tests replacing them. Those are the per-test sums, not the two wall clocks, which read 3.3s and 3.4s and flattered the replacements: they were separate npx playwright test invocations, so each paid its own cold start (~0.6s and ~1.9s) and a near-tie appeared between two things not measured the same way. Warm inside a full suite those three cost under a second, which is why the contaminated baseline's clock barely moved.

-

Eleven tests at 2.7s cannot buy a twenty-second saving, and they did not. Essentially all of 36.2s → 15s was two shell.spec.js tests that waited out the 8s watchdog in real time, and one of those two was the routing guard this section explains should never have gone. So the deletion of the eleven bought shape, not speed; the speed came from a deletion that cost coverage. Both are back at ~75ms each, because page.clock fakes the page's timers — the honest version of the trade: the wait was the cost, not the test.

-

Cutting Playwright tests barely moves the CI number in any case, because roughly four minutes of that job is three wasm-pack builds, npm ci and a browser download — not test execution. The feedback-time win is the js job, which needs no Rust toolchain and no wasm bundle at all, and that is a constraint rather than an accident: the moment a web/tests/*.test.js file needs web/pkg/, it inherits the four-minute build and the argument for it evaporates. That is why the one test that does need the bundle lives in web/tests/bundle/ and runs in the browser job instead.

-

Until improvise-4ktt the mechanism was a directory convention and a glob, and nothing checked it. bundle_freedom.test.js does now: it reads its own sibling files and refuses one that names the bundle directory, and its companion asserts web/tests/bundle/ still holds a test that does — a scoping rule is uninteresting if nothing falls outside it. Evidence in context/test-evidence/tooling.md.

-
-
- - diff --git a/site/docs/code/command.html b/site/docs/code/command.html deleted file mode 100644 index 72f49689..00000000 --- a/site/docs/code/command.html +++ /dev/null @@ -1,622 +0,0 @@ - - - - - -improvise-command — improvise docs - - - -
- -
-

Source: context/code/command.md

On this page

improvise-command — the arguments

-

The reasoning that used to live in this crate's comments (improvise-9c8c). Read it with the code open; it is written for someone about to change one of these files, not as an overview. context/repo-map.md is the "where", context/design-principles.md is the "why in general"; this is the "why here".

-

Test bug-narratives live in context/test-evidence/command.md, keyed by test name.

-
-

#ui/reduce.rs — the one door from a command to the authoritative state

-

#What the module is (improvise-gxi, corrected by improvise-s17o)

-

This module used to say that every deployment mode — the native TUI, a websocket server, a browser worker — reaches the model through it. That was aspirational, and it is what let improvise-ondj's missing basis check sit unnoticed. The sentence described a door nobody walked: reduce_full had no production caller anywhere in the workspace, so nothing exercised its Command::Model arm and nothing could notice it skipped the check. A door advertised by documentation and used only by its own tests is exactly the shape that hides a defect until the next host arrives — which is the general form, not an anecdote about one bug.

-

So reduce_full is gone, and the argument for deleting rather than wiring it up is in its own last doc comment: "Per-client grid updates need a viewport and come from Sessions::project_all." It returned half of what a host needs. The browser worker was the natural candidate caller and was being built at the time; it reaches the model through Host::handle → Sessions::apply_checked, because that is the half that has the viewport. A function that hands a host half its answer is not a host API, and the test helper that was its only caller now spells the two lines it was.

-

What remains, and what each is for:

-
    -
  • apply_command takes a Checked — a Command the authoritative state has answered for — and applies its document half. That vocabulary is derived from the effects: the view slice from the ViewOnly ones (decision(improvise-cqi, command_set_derived_not_invented)) and the document from the EffectKind::Model ones (improvise-kmgs). Its production callers are Sessions::apply_switch and apply_model, which pass only SwitchView and Command::Model.
  • -
  • reduce_key takes a keypress and runs it through the keymap, registry and effect pipeline. A keypress names an intent whose meaning depends on the CmdContext (where the cursor is, what mode is current); a Command names the mutation that intent resolved to. That is why the key path survives the vocabulary rather than being replaced by it. It returns the document-scoped projections to send onward; the grid is per-viewport and comes from Sessions::project_all instead.
  • -
-

What is not two is the mutation: model_effects maps a ModelCommand back to the effect it was derived from, and both paths run it through App::apply_effects. Effects remain the only place the App is written.

-

apply_command's remaining arms no longer fold. They used to route a wire view command into app.view_state — the authoritative slice — which nothing in production did and nothing should: Sessions::apply_checked folds it into that client's mirror, because the model is shared and the UI is not (principle 5, gotcha 36). A client's SetStatus moving the host's status line is not a feature the module was missing a caller for; it is behaviour that was safe only because the arm was dead. a_view_slice_command_does_not_move_the_authoritative_view_state now asserts the inverse of what the old test asserted.

-

A wire command arrives here through Checked, and nowhere else (improvise-ondj, ADR 001). The basis check is a property of the session door rather than of the mutation path — a host holding the App is the truth and not a claimant — but "the mutation path does not check" was implemented as a second door rather than as an exemption: both functions used to take a &Command and their Command::Model { cmd, .. } arm discarded the basis with .., which is the syntax for ignoring a field and costs no compile error. So the answer is not a guard but a type: the only way to obtain a Checked over a Command::Model is Checked::against, which runs stale_basis against the authoritative App. A host that wants to apply a client's batch must face the Rejection, and Sessions::apply_checked is the door a host goes through.

-

Host::commands answers the whole frame first with CheckedBatch::against, so Sessions::apply — which answers one command and then dispatches — is not a door a host may use: checking per command is the lost update improvise-955n fixed. It is now #[cfg(test)] (improvise-s17o), which makes "no production caller" a fact the compiler keeps rather than a note verified at one commit, and leaves apply_checked as the one door.

-

#Checked — a wire Command the authoritative state has answered for

-

The one thing it makes unrepresentable: a Command::Model applied without its basis being checked (improvise-ondj). The inner field is private and Checked::against is the only constructor, so there is no spelling of "apply this batch and ignore what it claims" — the .. that used to discard the basis is now a Rejection the caller must handle.

-

It is not a claim about which App was asked: a host with two documents could check against one and apply to the other. That is not a hole this closes, because a Sessions is already bound to one document's generation sequence (ADR 001) and a Registry hands out one pair at a time.

-

#Why the lifetime does not close it (improvise-7v62)

-

The obvious answer — make the lifetime the &App's, so the borrow checker forbids checking one document and applying to another — was tried, and it is not merely inconvenient: it is contradictory with what the type is for. A Checked is a proof discharged before a mutation, and its whole job is to be carried across that mutation; a borrow of the App is a claim that must stay live during it. The two cannot both hold. Tying them makes every call site an E0502, starting with the canonical one:

-
error[E0502]: cannot borrow `*app` as mutable because it is also borrowed as immutable
-    match Checked::against(app, cmd) {
-                           --- immutable borrow occurs here
-        Ok(checked) => self.apply_checked(app, id, checked),
-                       ^^^^^ mutable borrow occurs here
-

A generative brand would express it without borrowing, but that needs a closure-scoped API and would distort every host. The remaining option is carrying a document identity, and DocumentId lives in the server's registry: an App does not have one and does not want one, precisely because the unit of authority is one document per App. So this is a deliberate won't-fix, not an oversight. What keeps it unreachable is stated above and is load-bearing — improvise-j31x made this a multi-document server on purpose, and ADR 001 never evicts a document so that a reconnecting client cannot have a stale basis accepted as current.

-

#CheckedBatch — a whole frame, answered before any of it is applied

-

(improvise-955n / improvise-08wb, ADR 001.)

-

Checked alone was not enough, and the gap was exactly one level up from the one improvise-ondj closed. A Checked answers for one command against the App as it stands; a frame is a sequence of them, applied in order, so a frame whose second command was refused had already applied its first. ADR 001 says a stale batch is never applied, not even partially, and that was true of a single command and false of a batch.

-

#One basis, checked once, and then the chain runs

-

The contract, as the user stated it: "a sequence of commands sent to the server should assume that it's played in order from start to end and that each command sees the state after the previous command runs", sent as "a single transaction from a given base". Four clauses:

-
    -
  1. A frame states one basis, describing the base the client composed against.
  2. -
  3. That basis is checked once, against that base, before anything is applied.
  4. -
  5. Commands are applied in order, and command N sees the state command N-1 left. Data dependence between them is expected, not an error.
  6. -
  7. All or nothing. If any command fails, the frame leaves the document exactly as the base described.
  8. -
-

Clauses 1–3 are implemented. Clause 4 is not — see the hole below and the design note on PR #26.

-

#Two of the three tiers hoist, and the third does not

-

Clause 2 hoists the check, but not every tier is a frame-level fact, and a first version hoisted all three. That was a lost update: document_generation and layout_version describe the document, so answering them once for the frame is exactly right — but the record tier asks "is the client's picture of this record current", and which records are at stake is a property of the command. That is why records_named is an exhaustive match over ModelCommand and not something a frame could summarise (gotcha 43).

-

Skipping it for later commands let [SetCell(North), SetCell(South)] overwrite a South that somebody else had moved, silently, with the frame reported accepted — the precise loss ADR 001's record tier exists to prevent, and invisible to the other two tiers because a SetCell is CellData and moves neither of them. So every mutation answers for its own records, against the frame's basis: the one the first basis-carrying command stated, not each command's own field, so a later command cannot state a fresher claim than the frame was composed under.

-
#Except the records the frame is creating
-

The argument that motivated clause 3 is true, and it is true of a narrower thing than it was first applied to. A client cannot claim a counter for a record its own chain is about to create, because BasisCache::claim filters to records the cache has been told about — so [AddItem(East), SetCell(East)] would be refused by a naive per-command check however it is ordered. But that argument was then stretched to records the frame merely writes, and those are the ones the tier is for.

-

So the exemption is stated over the property rather than over a list of commands: a record that neither resolves in the document nor is claimed by the basis is one the frame is about to create, and is not answered for. Every other combination still is — including a record that does not resolve but is claimed, which is a client believing in something that has been removed, and is RecordGone as before. See will_be_created_by_the_frame.

-

What the base means is therefore: what the client saw. The transaction promises the whole chain lands on top of it or none of it does. The chain's internal coherence is the transaction's job, not the basis check's.

-

#The hole clause 4 leaves, stated rather than hidden

-

App::apply_effects aborts without undoing (gotcha 21 — an abort is not a rollback), so a frame whose second mutation fails leaves the first applied. [RemoveItem("North"), SetCell(the North record)] is the example: the removal lands, the write then resolves to nothing and model_effects returns an empty batch, so the frame is reported accepted having applied half of itself. Both halves of that are open — the partial application and the silence.

-

The create exemption has a residue of the same kind and it is the same hole: a later command naming a record that is neither present nor claimed is presumed to be one the frame creates, so if the frame does not in fact create it, the write resolves to nothing and silently does not happen. That is model_effects' empty batch again, not a second defect, and it closes when clause 4 makes a mid-chain failure reportable.

-

Both are unreachable from any client in this workspace: the only producer of a multi-command frame is improvise-wasm-client::keys::commit, which emits one mutation among view commands. Reachability is a reason not to rush a fix and not a reason not to notice one — the lost update above had the same reachability argument as this hole and none of its documentation, which is how it shipped. That is what makes it safe to document rather than rush, and it is why an earlier "at most one document mutation per frame" rule — which closed the hole by forbidding the capability — was rejected.

-

#Two things the rule that was rejected got wrong, worth keeping

-

Arity is not a property of a variant. A future Command::RunScript is one variant performing an unbounded number of mutations, so no count over the vocabulary can be right: a classifier can honestly answer whether a command changes the document, never how many times. A mutates_the_document predicate written for the rejected rule is deleted with it rather than kept unused — but it was made an exhaustive match on review, because a matches! answers false for a future variant in silence, and clause 4 will need that classifier back. Write it exhaustive when it returns.

-

The transaction boundary is not the frame. Because one command may already be a chain, clause 4 is a property the system needs inside a command as much as across a frame of them, which is why the design note puts the mechanism at App::apply_effects rather than at the batch.

-

#CheckedBatch::against

-

Answer for every command in a frame, against the state the frame was composed against — which is the state before any of them runs. The refusal is the frame's answer and nothing is applied. It is a Result rather than a per-command verdict for the same reason Checked is a type rather than a guard: the only enforceable version of "answer before applying" is not being able to reach the commands without having done so.

-

The two document tiers are answered once, on the first command that carries a basis — clause 2 — and every later command is admitted without re-asking them, which is clause 3. The record tier is asked of every mutation, against that same frame basis, because it is not a frame-level fact. A frame with no mutation answers trivially.

-

The Basis on a later Command::Model is ignored rather than compared, and that is an honest interim rather than the end state: clause 1 says the frame states one basis, but the wire still carries a Basis inside each Command::Model — right when one command was one transaction, and now one field per command describing something that belongs to the frame. Moving it to ClientFrame::Commands would make "one basis per frame" unrepresentable-otherwise instead of a convention, and that protocol change belongs with clause 4.

-

Inside the loop, frame_basis is the one its first mutation stated; later commands' own Basis fields are deliberately not read, for the reason above.

-

#Checked::against

-

Total over the vocabulary, and only Command::Model can be refused: everything else is either this client's own view slice or a SwitchView, which carries no basis and cannot corrupt — the workbook simply refuses a view it does not have (ADR 001 names that second hole and leaves it open deliberately).

-

Saving is not in this vocabulary and deliberately so: it is ClientFrame::Save, answered by the host, because it mutates no shared state and has no basis to answer for (improvise-368h).

-

There is no argument that suppresses the answer. ADR 001's force is a fresh batch under a fresh basis, composed by the client; it is not a way past this constructor.

-

#Checked::within_a_frame

-

A command in a frame whose document tiers have already been answered (clause 2), answering only for its own records. The two document tiers are not re-asked, which is clause 3: they describe a document this command is not running against, since its predecessors have moved it. The record tier is asked, because it is a fact about the command rather than the frame.

-

Private, and that is what keeps it from being a bypass: from outside the module the only doors are still Checked::against and CheckedBatch::against, and the only caller is the loop that has just answered the frame's basis.

-

#Checked::command

-

Hands back a &'a Command rather than one borrowed from &self, so a caller can dispatch on the variant and still move the proof into the function it dispatched to. Crate-private: a proof that could be unwrapped from outside would be a proof nobody had to obtain.

-

#apply_command

-

Command::SwitchView does not go through reduce_view alone, but not for the reason it once did. Since improvise-jytm the document's copy is the default — what a fresh client opens — and the client's slice is authoritative, so a switch moves no document state at all. What it still has that reduce_view has not is the view history: effect::SwitchView pushes the frame effect::ViewBack pops, and that stack is this client's. Everything else is pure view slice.

-

The SwitchView arm specifically: the mutation is effect::SwitchView's, not a second copy of it. The effect records the view history the key path leaves behind, and effect::ViewBack pops exactly that stack. Switching by calling App::switch_to_view here instead left the stack empty, so a wire client could not go back where a keyboard one could — see a_wire_view_switch_records_history_the_way_a_key_switch_does. apply_effects rebuilds the layout.

-

The Dirty that arm returns is not Dirty::of of anything, and since improvise-jytm that is the point rather than a narrowing. effect::SwitchView is ViewOnly, so its Dirty is NOTHING; what this arm returns is what the host's own widgets must repaint, which is a different question from what the document changed. The view list is left out for the old reason (improvise-pca): its names and axes belong to the document, and which view is active is not in ViewListCache at all. A connected client never reaches this arm — Sessions::apply_switch moves that client's mirror and ClientSession::project observes the move.

-

An inbound Command::Project is ignored. Projections travel server→client; a client that sends one is describing state it does not own.

-

The Command::Model arm needs no basis check of its own: the basis was answered for by Checked::against, and there is no other way to have got here with that variant.

-

#model_effects

-

The other half of the derivation: a ModelCommand back to the effect it was derived from (improvise-kmgs). This is the seam that keeps a command from becoming a second way to mutate. A ModelCommand names a mutation; it does not perform one. The effect it names is the same value the keymap path builds, and it is run through the same App::apply_effects, so both doors go through the one mutation site and both get their Dirty from the effect's own EffectKind rather than from a second classification that could drift.

-

The match is exhaustive on purpose: a new ModelCommand variant fails the build here until it names an effect, exactly as a new effect fails the build until it classifies itself (gotcha 3).

-

It takes the App because two variants name their operand by surrogate rather than by coordinates (ADR 002), and resolving one is a lookup against the authoritative state. Resolution failing is not an error path bolted on: it is one of the two arms of staleness — the record is gone, a category or item it named was removed — and the answer is an empty batch, which applies nothing. Sessions::apply reaches that same conclusion first and typed, as Rejection::RecordGone; this stays as the floor under the key path and under any host that bypasses the session door.

-

#records_named — which records a ModelCommand writes

-

Decided by the server (ADR 001, improvise-fkfg). This is deliberately not "whichever records the client's basis mentions". A client that simply left a record out of its claim would otherwise have a force by omission — the record tier skipped, with no flag to remove and nothing in the message saying so — and force is supposed to be a re-composition against the current basis, never a bypass of the check. The server decides what is checked; the client only supplies what it saw.

-

The match is exhaustive, and the long list of "names no record" is the point rather than noise: a new variant that writes a record fails the build here until it says so. That matters because the failure mode is silent — an unlisted record-writing variant would simply never be checked against its own tier, and the suite would stay green.

-

Only CellData variants that carry a RecordCell name a record. The item and formula commands are CellData too but name no record: they move the axes a record's coordinates are drawn from, which is what the layout tier is for.

-

#stale_basis

-

None means apply it. The three tiers are checked in order of how much they invalidate, so the answer a client is given names the largest thing that moved rather than an arbitrary one: a document swap is not usefully reported as "a record moved".

-

The record tier separates structural from data staleness, and keeping them apart is the whole reason ADR 002 put a maintained RecordHash index in DataStore rather than regenerating one from the layout. A record that does not resolve is gone — there is nothing to write to and nothing coherent to force, because force never creates structure. A record that resolves at a different version merely changed, and force there is one keystroke.

-

Nothing in it mutates, and there is no argument that makes it return None regardless: a stale batch is never applied, and ADR 001's force is a fresh batch under a fresh basis rather than a way past this function.

-

#stale_records_in_a_frame

-

The record tier for a command inside a frame whose document tiers are already answered. It reuses stale_record unchanged — one home for what each of the four combinations means — and differs only in which records it asks about: the ones the frame is about to create are skipped.

-

#will_be_created_by_the_frame

-

A property, not a list of commands. The document does not have it and the basis claims nothing about it — which is precisely what "I am creating this" looks like from here, because BasisCache::claim can only state a counter for a record the client has been told about.

-

The near miss is the other half of that conjunction: a record that does not resolve but is claimed is a client believing in something that has been removed, and stays Rejection::RecordGone. Only the pair exempts.

-

It is asked only inside a frame, never of a lone command: a first command has no predecessor that could have created anything, so Checked::against still refuses a write to a record that is not there.

-

#caught_up

-

The command a client that is fully caught up would compose. It reads the tiers straight off the App, which looks like forging and is not: it takes &App, so only the side that is the authoritative state can call it, and for that side "the current basis" is not a claim to be checked but a fact. A remote client has no App and no other constructor — BasisCache::claim withholds until the server has spoken — so nothing reachable from a wire can get here.

-

It exists for two callers: a host that resolves a command locally and wants to push it through the same door a client's would take, and the tests whose subject is not staleness. The tests whose subject is staleness build the Basis by hand, so neither kind can pass by accident through the other's convenience.

-

#reduce_key

-

Resolve a keypress through the keymap, run the commands, apply the effects, and report what to send onward. The returned commands are the document-scoped projections; per-client grid updates need a session's viewport and come from Sessions::project_all, driven by the Dirty that apply_command returns.

-

It used to have a wire-command twin, reduce_full, described right here as "the signature the three hosts share". None of them shared it; the module section above says why it is gone (improvise-s17o).

-
-

#ui/effect.rs — the only mutation site

-

#EffectKind has no default, deliberately (improvise-mae, gotcha 3)

-

EffectKind::ViewOnly changes only the acting client's own view — cursor, scroll, mode, panels, minibuffer text, drill staging, status line — and is never projected. EffectKind::Model(ModelChange) changes something every client renders.

-

Cursor and scroll live in the shared View (they are persisted in .improv), but they are a per-client concern: one client scrolling must not move another's viewport. See improvise-8uz.

-

Effect::kind deliberately has no default: a new effect that forgot to classify itself would silently never reach connected clients, so the compiler asks. The three traps a classifier walks into are gotcha 3's and are not restated here.

-

#Effect::apply stays on &mut App — declined, with counts (improvise-drg)

-

improvise-drg proposed narrowing each apply to the slice it touches: &mut ModelState or &mut ViewState where an effect only needs one, &mut App for the rest. The win it claimed is type-level documentation and finer-grained tests, explicitly not line count. Declined, on four grounds.

-

The narrow set is shrinking, for an architectural reason. Counted over all 57 effects — 49 impl Effect blocks plus the 8 simple_effect! invocations, which also expand to one — 16 touch only view_state, 9 only model_state, and 32 cross-cut. At the wave's base commit it was 16 / 16 / 25, so the narrow set went from 32 to 25, and the seven movers are SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, RemoveItem and RemoveCategory — every one of them an effect improvise-9mo9 gave a reset_cursor.

-

These are the corrected figures, and the first ones were wrong twice. They said "25 to 17 of 49": they omitted the simple_effect! invocations, all of which are view-only, and they misclassified AddItem at the base commit because the extraction regex could not see app followed by a newline and .model_state. That is doctrine(derive_then_verify) catching the author of this paragraph with the exact failure it was written about — the kb entry names multi-line method chains and three effects they had already mis-sorted. The correction weakens this bullet and is recorded rather than quietly restated: a narrow set of 25 of 57 is 44%, not the 35% first claimed, and the fall is 22% rather than 32%. The direction survives; the headline was overstated.

-

The reason the direction is what matters: a grid reshape lives in model_state.workbook and the cursor it invalidates lives in ViewState, so the effects that matter most are exactly the ones that reconcile the two. Gotcha 3 already records the same asymmetry from the other side — the View lives inside model_state.workbook, so touching model_state does not mean being a model change. The App is where the slices meet, and narrowing bets against the direction the code moves in.

-

The type-level documentation exists and is checked. Effect::kind() has no default, so the compiler demands the classification (gotcha 3), and apply_effects reads that one answer for the Dirty, and for the CellData and WholeWorkbook half of model_state.dirty (gotchas 40, 45; the shape tiers are observed rather than declared — see apply_effects below). A narrowed signature would be a second statement of what an effect touches, free to disagree with the first. Two answers to one question is the defect this repo names, not the cure.

-

Every mechanism the ticket offered weakens a guard. Its (A), three traits, needs Vec<Box<dyn Effect>> to become three queues or an enum wrapper: three queues destroy batch order, which gotcha 21's abort, gotcha 23's stale layout and gotcha 43's frame contract all lean on, and an enum wrapper is a fourth classification beside EffectKind. Its (C), default methods falling through to apply(&mut App), gives apply a default — the precise thing kind() does without, and for the precise reason: an effect that overrode apply_view but needed the model would compile and write the wrong slice silently, and one that overrode neither would compile as a no-op. Its (B) rebinds inside the body and does not meet the ticket's own criterion.

-

Finer-grained tests are not blocked today. effect.rs's 64 tests already call Effect.apply(&mut app) against an App::new(wb, None) built in a line; narrowing the signature would not shrink that fixture.

-

The 17 that could narrow may still be worth reading as narrow, which is approach (B) — rebinding the slice at the top of the body. That is a style choice inside a function, not a trait change, and it is not being mandated.

-

#simple_effect! — the kind argument is mandatory (improvise-8fy)

-

Declare a setter effect: a one-field newtype whose apply writes that field into one place on the App.

-

The kind is a mandatory argument, even though every call site so far answers ViewOnly. Hard-coding it reintroduced exactly the default that Effect::kind deliberately does without (gotcha 3): $place is an unconstrained expression, so

-
simple_effect!(X(String) => |app| app.model_state.workbook.active_view_mut().number_format, …)
-

is a textbook LayoutGeometry write that the macro would have reported as ViewOnly, silently, with the whole suite green. Writing the answer per call site is the same reasoning that gave kind() no default.

-

Within that, the macro is still the single hook for anything these setters should all do — today none of them marks the document dirty or rebuilds the layout, and the macro makes that one decision instead of fifteen silent ones.

-

Clone::clone rather than .clone(): the field is usize or bool as often as it is a String, and the call form keeps clippy::clone_on_copy off the generated code.

-

#AddCategory aborts the batch on refusal

-

A refusal aborts the batch, for the same reason a failed save does (gotcha 21): what follows assumes the category exists. From the +cat column (improvise-2zq) the rest of the batch would advance the cursor and re-open an editor on a column that was never created — and re-opening the minibuffer is also what would hide this message, since draw_bottom_bar renders the minibuffer instead of the status line.

-

MAX_CATEGORIES is a reachable refusal on that path rather than a theoretical one, and the limit is checked in exactly one place — Model::add_category — so nothing in the effect restates it.

-

#AddFormula and non-_Measure targets

-

For non-_Measure targets the item is added to the category so it appears in the grid. _Measure targets are dynamically included via Model::measure_item_names() (gotcha 7).

-

#RebuildLayout (improvise-ehz)

-

apply_effects rebuilds the layout once after the whole batch, so any effect that must read the layout after an earlier effect changed it needs a RebuildLayout in front of it. That is gotcha 23; the command decides the order.

-

#EnterEditAtCursor — the mode is the caller's, never app.view_state.mode

-

Re-enter edit mode by reading the cell value at the current cursor; used after commit+advance to continue data entry. It reads the current layout, so emit RebuildLayout first when an earlier effect in the batch moved the cursor or changed the grid.

-

target_mode is supplied by the caller (a keymap binding via EnterEditAtCursorCmd, or CommitAndAdvance from its own edit_mode field). The effect itself never inspects app.view_state.mode: the mode is decided statically by whoever invoked it. An earlier version branched on app.view_state.mode.is_records() and got it wrong mid-batch.

-

#Which effects reset the cursor, and why it is decided one at a time

-

Swapping the axes renumbers every row and column, so the old indices name different cells. That reset used to live inside View::transpose_axes; it moved here with the cursor (improvise-8uz, gotcha 24).

-

Nothing declares which effects renumber the grid, deliberately — so for a long while only TransposeAxes and CycleAxis reset, while eight siblings that renumber just as thoroughly did not (improvise-9mo9). SetAxis was the sharp case: CycleAxis is SetAxis to Axis::next(), so one of a pair that differ by an argument reset and the other did not. The gap was invisible in both directions, because reset_cursor is also the only thing that bumps cursor_epoch, which is the only thing Sessions::adopt_cursor_epoch watches: a forgotten reset leaves the host's own cursor stale and tells no client.

-

Ten effects now reset, and the test that holds them there (every_reshaping_model_command_invalidates_every_cursor) asserts of each case that the grid's row_items/col_items really changed before asserting anything about a cursor, so a case that has stopped reshaping fails rather than passing vacuously. The per-effect arguments:

-
    -
  • SetAxis rebuilds the cross product out of different categories, and a cats change re-identifies every position, not merely the ones whose labels differ (gotcha 44). HideItem/ShowItem and ToggleGroup each add or drop a run in the middle of an axis, so everything after it shifts. TogglePruneEmpty drops or restores every empty row and column at once.
  • -
  • AddCategory/RemoveCategory are cats changes in every view, since Workbook::add_category calls View::on_category_added on all of them and on_category_added lands a regular category on Row or Column.
  • -
  • AddItem and AddItemInGroup reset only on success, because a refused add reshapes nothing and the epoch is document-wide: the first version of this change reset after the error branch, so a typo'd category name sent every client a ResetCursor for a change that never happened.
  • -
  • AddItem/AddItemInGroup/RemoveItem are the ones worth spelling out, because "an append cannot move an existing position" is true only of the outermost category on an axis. expand_category runs inside the cross product, so an item appended to an inner category repeats once per outer block: adding a third Product inserts a row into the North block and pushes the whole South block down. RemoveItem is a shift_remove and moves positions on any level.
  • -
  • The condition is "did this change anything", and it took three passes to get right. A refused add reshapes nothing, so the first version — which reset after the error branch — sent every client a ResetCursor for a typo'd category name. The second version gated on Ok, which is still wrong for six of them, because accepted and changed-nothing are different: SetAxis to the axis a category is already on, HideItem on an already-hidden item, ShowItem on a never-hidden one, AddItem of an existing item (add_item dedupes and returns the existing id), and RemoveItem/RemoveCategory of something absent (both infallible no-ops). Each now asks the state before mutating. The one-keystroke case that made this urgent needs no second client: TileSelect binds r to set-axis-for-tile row, which emits SetAxis with no comparison against the current axis, so r on a Row category sent the user's own cursor to the origin.
  • -
  • The predicate must ask the same question the grid does, and twice it did not. Review found six commands the document accepts where the guards still reset. Four were the guard being absent or asking the wrong thing: SetAxis on a category the view does not register (set_axis no-ops, but try_axis_of returns None, which compares unequal to Some(axis)); HideItem/ShowItem naming something the grid never drew (hide_item records any string); and ToggleGroup naming a group that does not exist, which never got a guard at all because it merged cleanly through the #48 merge and so nobody looked at it. The fifth is the one worth remembering: AddItem { _Measure, <a formula target> } passed a has_item that read category.items, while _Measure's effective items are explicit ∪ formula targets (gotcha 7) — so the guard said "new" about a column that was already drawn. That is fixed by observing rather than predicting: the three item effects now compare effective_item_names across the mutation, which is correct in both directions at once and needs no separate rule for dedupe, for formula targets, or for an unknown category. The sixth, TogglePruneEmpty on a grid with nothing empty, is not fixed — guarding it means computing the pruned layout — and it is one keystroke in ordinary use, which is why it is filed rather than patched.
  • -
  • This is the cost of a per-effect table, and it is the argument for improvise-ni6k. An EffectKind cannot say whether anything moved, so six effects each had to learn to answer separately — six chances to get it wrong, three of which were taken. Observing the grid frame across a batch answers it once, and would close improvise-7kvw for free. It is not done here because it belongs in App::apply_effects, which three other branches were editing.
  • -
-

What does not reset, and why:

-
    -
  • SetNumberFormat changes how a number is drawn and never which cell a position names. It is the negative arm, and without it "reset on every model command" would pass the positive test.
  • -
  • CreateView, DeleteView, SwitchView, ViewBack and ViewForward move which view is active. ViewState::cursors is keyed by view name (gotcha 24), so the cursor being read after the switch is a different one and the outgoing view's entry stays valid for when the user comes back.
  • -
  • SetPageSelection changes which records are visible, not which categories frame them — except under prune_empty, where a page with different empty rows really does renumber. That residual is improvise-7kvw rather than fixed here, because [/] is navigation and sending the cursor home on every page flip is a worse answer than the hole.
  • -
-

Drilling is the case this ordering has to survive, and it is tested rather than argued (drilling_resets_only_the_drill_views_cursor). DrillIntoCell and ToggleRecordsMode each issue several SetAxis effects, so each now resets several times — but both push SwitchView first, and reset_cursor writes the active view's cursor, which by then is the freshly created _Drill/_Records one. The view drilled from keeps its entry in ViewState::cursors. What is still only argued is the cursor_epoch: a drill bumps it once per SetAxis, Sessions::adopt_cursor_epoch compares before and after per command so it relays once rather than eight times, and DrillIntoCell is reachable only from the key path today — Host::handle answers ClientFrame::Key with an empty vec — so no client sees it at all. That changes when drill becomes addressable.

-

The one caller that depended on a missing reset is CommitAndAdvance's inserts_a_column branch: typing into the +cat column creates a category and then deliberately stays put, so the cursor lands on the column it just created (improvise-2zq). That worked because nothing moved the cursor. Now AddCategory does, so the branch ends with an explicit SetSelected back to where it was — which states the intent instead of inheriting it from an absence, and still sits after AddCategory's abort so a refused category leaves the batch unchanged.

-

#ReturnToNormalUnlessMoved — observation, not prediction (improvise-toc)

-

Return to Normal unless something earlier in this batch already moved the mode away from from — the mode the batch started in.

-

ExecuteCommand used to decide this up front by asking each effect a per-type changes_mode(). That cannot describe ViewBack, ViewForward or StartImportWizard, which swap the mode only on some runs: answering "no" snapped a successful :import straight back out of the wizard, while answering "yes" would have stranded a failed one in CommandMode. Reading the mode here, after the batch has run, needs no prediction. That is gotcha 31.

-

#App::normal_mode — what Normal means on this grid (improvise-sihr)

-

AppMode::Normal and AppMode::RecordsNormal are the same mode over two layouts, and every minibuffer mode opened from a records grid used to exit to the first: the exit was a constant, spelled in TextEntry::new(.., "normal") and again in each commit command, and nothing carried where the user came from. Three arms — :, Ctrl-E and Ctrl-W — and the class predates the last of them, which is why Copilot's finding against SavePrompt alone was declined on PR #27.

-

It is resolved once, at the end of the batch. App::apply_effects calls settle_normal_mode after rebuild_layout, and that is the only caller: Normal and RecordsNormal are one mode over two layouts, so the mode a batch ends in must match the grid it ends on. No effect resolves anything — ChangeMode is an ordinary simple_effect! again, and ReturnToNormalUnlessMoved and CloseWizard write plain Normal. The ticket proposed the opposite — an exit mode beside MinibufferConfig — and it was declined for a reason beyond doctrine: carried generically it also returns CategoryAdd to CategoryPanel, which nobody asked for, and carried for three modes it is the one-arm shape the ticket itself argues against.

-

Resolving per-effect was tried first and was wrong, in a new direction. Set at the moment the mode is written, the resolution reads the batch's opening layout (gotcha 23), so a batch that crosses — :toggle-records-mode . enter-mode normal — settled against the grid it started on and left a records mode over a pivot grid. One edit, after the rebuild, has no crossing case to get wrong, and it also removes the per-edit coverage question three separate writers created.

-

The guard is two-armed and the crossing test could only see one arm (improvise-4dpj). settle_normal_mode matches Normal | RecordsNormal, and replacing that with a one-way Normal left the whole workspace green: the : crossing drives the layout both ways, but both of its branches end in enter-mode normal, so the mode handed to the settle is AppMode::Normal either way. It discriminates "settles at all" and "settles against the ending layout", and not "settles from either mode" — three questions that look like one until an injection separates them. The RecordsNormal arm is live: SetAxis { "_Index", Axis::None } applied from a records grid leaves is_records_mode() false with the mode still RecordsNormal.

-

And records-ness is auto-detected from the axes (gotcha 1), so a view switch crosses it too. SwitchViewAtCursor emits [SwitchView(target), ChangeMode(Normal)], which is a genuinely different path from the toggle the : test drives — nothing in it says "records", the crossing falls out of the target view's axis map. Moving the settle back before rebuild_layout is the injection for both, and it fails each by name.

-

This fixes the TUI and not the browser, and that is LIVE (improvise-e5pf). Command::ChangeMode(AppMode::Normal) is on the wire and reduce.rs folds it as view.mode = mode.clone() with no resolution, so the browser front end still has the improvise-sihr bug. The live browser key resolver is improvise_protocol::resolve — re-exported as improvise_wasm_client::resolve — and it emits that command in five places. An earlier version of this paragraph said the gap was latent because "keys.rs emits no ChangeMode", which is true and irrelevant: keys.rs holds only BrowserKey. Nothing checks a claim in context/, which is exactly why one must not be written from a grep of the wrong file (design-principles §6).

-

It compounds, and that is the part worth knowing: once the client's mode reads plain Normal, its only records-ness fact — state.view.mode.is_records() — is false, so a later commit over a records grid takes the non-records branch.

-

It is still not closeable here. By the time Esc arrives from a panel the client's mode no longer says records, and a client holds a GridCache rather than a GridLayout, so records-ness is not a fact it has at all. Closing it means enriching the projection, the shape gotcha 47 names, never moving resolution back into the host.

-

:toggle-records-mode is not the case that proves any of this, and an earlier version of this paragraph claimed it was. It works, but not for the reason given: ToggleRecordsMode emits change_mode(RecordsNormal) explicitly on entry, and on exit ViewBack restores the mode SwitchView pushed, so any resolution's answer is overwritten in both directions and the case exercises nothing. a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal drives the crossing in both directions instead, because a careful comment standing in for a test is what let the per-effect version ship.

-

#SetBuffer — "search" is not a buffer (improvise-gdl)

-

Every name goes into the buffers map. The search query is not a buffer — it has its own ViewState field and its own SetSearchQuery effect — and commands reach the right one through cmd::core::write_buffer, next to the read_buffer it mirrors.

-

#ApplyAndClearDrill

-

RecordCol::NewMeasure / NewCategory are never staged: commit_cell_value applies a new measure or category immediately, since a schema change is not a cell edit and has nothing to roll back with the drill.

-

There is deliberately no dirty = true in it: it declares Model(CellData) and App::apply_effects raises the flag off that (improvise-gv9r, gotcha 45). The line that used to be there was the last survivor of MarkDirty — harmless, and a second home for the one thing that commit set out to give exactly one.

-

#Save / SaveAs abort the batch (improvise-8tk / improvise-lae)

-

A failed save aborts the rest of the batch, so the Quit that wq/ZZ chains after it never lands and the error stays on screen. SaveAs is the other arm of that rule and was left out of the original fix, so :w /bad/path . q quit on a failed write. Gotcha 21 is the general statement.

-

#WizardOp

-

One discrete mutation of the import wizard's own state. Which op a key means in a given step is decided by HandleWizardKey; the enum is just the verb (improvise-2wm).

-

#AcceptWizardModel

-

Commit the wizard: build a workbook from its proposals and install it. On failure the wizard stays open carrying the error so the user can go back and fix their field choices.

-

#json_import_records / ImportJsonHeadless (improvise-oaq, improvise-k8i)

-

json_import_records extracts the records array from an already-parsed JSON import value. Precedence: explicit array_path → root array → first auto-detected array path. It is pure, which is what lets ImportJsonHeadless parse the file exactly once and reuse the parsed value as the pipeline's raw. CSV warnings (short rows, improvise-k8i) are threaded into the status message rather than written to stderr, which is invisible inside the TUI.

-

#AbortChain

-

Signals App::apply_effects to skip the remaining effects in the batch. The flag is reset at the start of every apply_effects call, so each dispatch starts clean. Use it when a sequence's premise no longer holds — "advance to next cell" at bottom-right — and later effects ("re-enter editing there") should be short-circuited.

-

#CleanEmptyRecords

-

Removes cells whose CellKey has no coordinates. These are meaningless records that can only be produced by AddRecordRow when no page filters are set. Pushed by ToggleRecordsMode when leaving records mode, as the inverse of the SortData that runs on entry.

-
-

#ui/app.rs — the authoritative state, and the three tiers read off one classification

-

#The slice boundary, and the residue

-

App is two named slices plus a residue of fields that belong to neither:

-
    -
  • model_state — document state (Workbook + IO bookkeeping). Persists. It is distinct from Workbook itself (pure document semantics, improvise-core) because file_path and dirty are persistence-layer concerns.
  • -
  • view_state — UI session state (mode, cursors, panels, buffers, …). Does not persist. The portable half lives in improvise-protocol (improvise-cqi) and is re-exported from ui::app so every existing caller keeps resolving.
  • -
  • The remaining direct fields are tagged in the source with one of five residue categories, so the boundary stays explicit:

    -
      -
    • derived cache — a pure function of the slices, recomputed on demand (layout). Owned by App because rebuilding belongs to the host.
    • -
    • runtime metadata — data the runtime feeds in: last_autosave (wall-clock debounce for autosave_if_needed), terminal dimensions.
    • -
    • transient — a flag whose lifetime is bounded to one method call on App, reset on entry, never observed across calls (abort_effects).
    • -
    • config — loaded once at startup, read but not written during a session: keymap_set, storage.
    • -
    • session-local — deliberately not portable and not snapshot-safe, so it cannot live in ViewState (improvise-h0b, gotcha 30).
    • -
  • -
-

wizard is session-local on both counts: ImportWizard is an improvise-io type, outside the allowlist a serialized ViewState may draw on, and a client that connects mid-import must not open onto someone else's half-finished dialog. drill_state likewise: it holds an Rc, so ViewState would not even be Send, and pending_edits are uncommitted writes belonging to whoever staged them. Its frozen records list is what records-mode layouts iterate, so records do not disappear when filters would change.

-

storage is where documents are read and written; the host chooses, and every effect that touches a document goes through it rather than std::fs, so the command layer has no filesystem of its own (improvise-fuj, gotcha 2).

-

DrillState::pending_edits is keyed by (record_idx, RecordCol) — the column as a RecordCol rather than an index, so applying them does not depend on the layout still being the drill layout.

-

#record_of and RecordHash — the unit of atomicity

-

record_of is a cell's coordinate tuple with _Measure dropped, re-exported from improvise-core where it lives beside the RecordHash it feeds: the surrogate is defined as the hash of exactly this, so two definitions of "the record" would be two things to keep in step (ADR 002).

-

It is the unit ADR 001 settles on, and the reason it is not the cell is gotcha 1: renaming a category coordinate in wide records mode moves every measure cell in the row, so one legal edit spans a whole record. A unit finer than the coarsest legal write cannot be maintained.

-

#record_fingerprints — a process-local XOR fold

-

A fingerprint per record of the raw cells it holds, keyed by the same surrogate the index and the version counter are keyed by. One u64 folded over each cell's key and value. The fold is XOR, so it does not care what order iter_cells yields — and it cannot cancel a pair against itself, because a key appears at most once in the store.

-

This hasher is not the surrogate's: it is process-local, compared only against a fingerprint taken moments earlier in the same process, and never crosses a wire. RecordHash is the opposite case and is specified accordingly (improvise_core::model::record).

-

The value hash is exhaustive and by bits rather than by Debug: two values that print alike must not fingerprint alike, and it runs once per cell per editing batch.

-

Only raw data is fingerprinted. Formula results live in the model's separate formula_cache, and a record whose derived value moved because someone edited a different record has not itself been written.

-

#document_generation, layout_version, cursor_epoch

-

document_generation — how many times the whole document has been replaced. Bumped by install_workbook and by nothing else, so a client mirror can tell that the document it was attached to is gone without the effect that replaced it having to reach the session list (improvise-cqq). Not part of ViewState: it describes the document's identity over this process's lifetime, not a client's view.

-

layout_version — shared document state: how many times the grid's shape has moved, bumped once per batch that is observed to move it, never off a declaration (ADR 001, improvise-xwi6; improvise-pdvm). The middle tier of the basis: a CellData batch composed before this moved is stale even if no record it names was touched, because the keys it carries hang off a shape that has changed under them.

-

cursor_epoch — shared document state: how many times a shape change has declared a cursor into this document invalid. Bumped by reset_cursor and nothing else (improvise-gvnb). It is a counter rather than a list of the commands that reset the cursor, for the reason gotcha 39 gives for watching document_generation instead of matching on LoadModel: which effects renumber the grid is a per-effect decision (gotcha 24 asks a new geometry effect to make it), so a table of variants here would be a second description of that decision, free to disagree with it. Observing the write is not.

-

#batch_aborted — the outcome, where abort_effects is the mechanism

-

abort_effects is transient by design — it is reset the moment the loop breaks, so nothing outside apply_effects can ever observe it — and that is right for the mechanism but leaves the outcome unreadable. A caller that ran a one-effect batch and needs to know whether that effect refused has no other way to ask: Effect::apply returns () on purpose, so an effect reports by writing the App.

-

Its one consumer is Host::save, where the batch is literally [Save] and so "the batch stopped" and "the save failed" are the same fact (gotcha 21, improvise-368h). Naming the real call site matters because that identity is what makes the flag readable at all: over a longer batch it says that something refused, never which. App::last_batch_aborted is the accessor and carries the same caveat.

-

#App::new recomputes formulas before the first layout

-

The formula cache is rebuilt before building the initial layout so formula-derived values are available on the first frame. The cache is keyed by the active view's None-axis categories, so the caller gathers them explicitly (gotcha 9).

-

#install_workbook

-

Replace the whole document. The client's view slice is scoped to the old one — its cursor map is keyed by that document's view names — so it is reset to the incoming document's active view rather than carried over (improvise-8uz, improvise-cqi).

-

Every connected client's mirror is scoped to the outgoing document in the same way, and the effect that got here cannot reach the session list. Bumping document_generation is how the reset reaches them: ClientSession::project compares it and reseeds.

-

#adopt_cursor_epoch — the epoch is one trigger of two, the view is the audience

-

reset_cursor resets the host's own slice; every connected client owes the same invalidation, and cursor_epoch is how the reset reaches them. The trigger stays observed — which effects renumber the grid is a per-effect decision, so a table of variants here would be a second description of it (gotcha 24).

-

The epoch is the host's own cursor, so it is blind to a view the host is not on, and since improvise-lltt a view-configuration command names its view, so such a write is reachable. The second trigger is App::last_reframed_elsewhere — the non-active views this batch was observed to reframe — and it is a precise set where last_reframed_views is deliberately a union with the reshaped set. Two consequences. A batch that reframes only elsewhere relays against that precise set, because the union would invalidate a client whose view merely reshaped (a page selection that prunes nothing), which paging_with_nothing_pruned_invalidates_no_cursor forbids. And the opening frames it diffs against are taken lazily, at App::view_mut_of — the one door a non-active view can be written through — so the held-key path still hashes one view and pays nothing for the wider question (the premise improvise-mcq4 narrowed that arm on was view_mut() writing only the active view, and that premise is gone).

-

The audience is not everybody, and has not been since improvise-jytm made "which view a client is on" per-client: a reshape of view A threw away the cursor of a client sitting on view B, which nothing reshaped. cursor_invalidated asks App::last_reshaped_views — the observed set, so AddCategory, which seats itself on an axis in every view, still reaches everybody — and the relay is one Broadcast::To(id, ResetCursor) per affected client rather than one ToAll.

-

An empty reshaped set means "not known", never "nobody": a batch that moved the cursor with no view observed to change shape gets the safe answer, everybody. Telling too few is strictly worse than telling too many — the diff then believes each client already holds origin-positioned content and sends nothing, so they stay stale permanently rather than until their next scroll. That is the half-fix gotcha 24 warns about, and cursor_invalidated is a named function with both arms tested so the fail-safe cannot be tidied away.

-

#record_version / resolve_record / resolve_aggregate

-

record_version takes a cell key or the record's own key indifferently — RecordHash::of normalizes. It is zero for a record nothing has written, which is deliberately also what a record that does not exist reports: a basis composed against an absent record and one composed against an untouched record are the same claim. The counters live in the DataStore beside the index that resolves a surrogate, because both are keyed by the record and both are invalidated at the same moment (ADR 002). They are still shared document state, never ViewState (gotcha 30); what moved is where on the document they sit, not whose they are.

-

resolve_record maps a client's record surrogate back to the cell it names (ADR 002). None means reject: there is nothing to write to. Two sources, in this order, and the order is the point.

-
    -
  • The index in DataStore is primary. It covers every record that holds data whether or not it is on screen, which is what a regenerate-from-the-layout scheme could not do: a record scrolled out of the viewport would have been indistinguishable from a record that no longer exists, conflating "apply" with "reject".
  • -
  • The layout is the fallback, and it is not a weakening of that. A cell with no value yet is named by the grid and by nothing in the store, and typing into an empty cell is the whole of improvise-1ey — so the layout is consulted for exactly the records that have no data to index. It is bounded by the grid rather than the viewport, so an off-screen empty cell resolves too, and a layout change correctly makes a surrogate composed against the old shape unresolvable.
  • -
-

resolve_aggregate goes against the layout and only the layout — not because that is cheaper but because an aggregate has no existence outside the layout that derives it. There is nothing stored to index. A layout change therefore makes an old aggregate id unresolvable, which is the right answer: the cell it named is not there any more.

-

layout_cell_keys excludes records mode: its cell_key is a synthetic (_Index, _Dim) pair carrying positions, which is the positional-identity trap ADR 002 rejected _Index for. A records-mode cell is projected from RecordRow::coords, which is already in the store, so the index answers it.

-

#switch_to_view and reset_cursor

-

switch_to_view points the view slice at name and switches the workbook to it. The two copies of "which view is active" must move together: the workbook's is the document's, ViewState's is the client's, and the client's is what keys the cursor map (improvise-cqi, gotcha 29).

-

reset_cursor sends the active view's cursor back to the origin, called when the grid's shape changes underneath it. It resets this slice, which on a server is the host's own and nobody's client. Every connected client owes the same invalidation, and cursor_epoch is how the reset reaches them: the effect that got here cannot see the session list, so Sessions::apply watches the counter instead — the same shape as document_generation and for the same reason (improvise-gvnb, gotcha 24).

-

#display_value_at_cursor / display_value_of

-

display_value_at_cursor is editable text for the cell under the cursor: a staged drill edit if one exists, else the layout's resolved value for synthetic record cells, else the stored cell; empty when the cursor is off-grid. It reads self.layout, so callers that have mutated the grid must rebuild_layout() (or emit RebuildLayout) first (gotcha 23).

-

display_value_of is the value an editor opened on key starts with — unformatted, and so not CellDisplay::text (design-principles §4: display rounding is view-only and must not feed back into the model). Crate-visible so project::cell_display can put it on the wire as CellDisplay::edit_text; a browser that un-formatted the drawn text instead would be a second derivation, and a lossy one.

-

#apply_effects — one classification, three readers

-

Apply a batch of effects and report which widgets it could have changed, so a host with connected clients knows what to project (improvise-cqq). The Dirty accumulates inside the loop: an aborted batch must not claim the effects it never ran (gotcha 21).

-

layout_version and model_state.dirty are no longer read off the EffectKind (improvise-75ld, improvise-pdvm). A kind says what an effect means to do, and both counters were believing it. Two failures, in opposite directions, from the one assumption:

-
    -
  • AddItem/AddItemInGroup/RemoveItem declare CellData and reshape the grid. improvise-9mo9 made them call reset_cursor, so cursor_epoch moved while layout_version did not — one event moving one of two counters that answer the same question, and a client told its cursor is invalid while its layout tier still reads current. (Since improvise-ni6k no effect calls reset_cursor; see "two observations" below.)
  • -
  • A refused or duplicate AddCategory declares LayoutGeometry and changes nothing. Model::add_category returns Ok for a name it already has, so the reachable case does not even abort. Every other client's next Command::Model was refused with Rejection::LayoutMoved, and the browser worker rewrote the whole OPFS document, for a keystroke that did nothing.
  • -
-

So a counter is bumped on an observed change, not on a declared intent — the answer record_versions already had, and the reason cursor_epoch is a counter rather than a table of variants. Two fingerprints are taken around the batch, both gated on the batch containing any EffectKind::Model(_):

-

view_shape is one view's drawn shape: the axis map, the effective item names minus this view's hidden ones, the collapsed groups that have something drawn under them, the page selection, prune_empty. Drawn, not configured — hide_item records a name whether or not the model has one, and a hidden ghost moves no row. view_shapes is that per view name, and it answers two questions: did anything move (layout_version), and which views moved (Sessions::adopt_cursor_epoch, improvise-nynk). layout_version is therefore bumped once per batch rather than once per effect; nothing reads it but "did it move".

-

#Two observations, not one (improvise-ni6k)

-

view_shape above is not the only fingerprint taken per batch, and the reason is the part worth keeping: the cursor's question and layout_version's question are different questions, and a single hash cannot answer both.

-

layout_version asks are the coordinates a client's keys hang off still the ones it composed against. The cursor asks does position (r, c) still name what it named. AddCategory of a category with no items separates them exactly: no row and no column moves, so no cursor is invalid — but every CellKey now needs a coordinate it did not before, so the basis tier must move. Unifying the two broke tests in both directions, which is how the distinction was found rather than reasoned to; do not re-merge them.

-

So view_frame sits beside view_shape, and last_reframed_views beside last_reshaped_views. view_frame hashes the drawn frame — row_cats, col_cats, and the AxisEntry sequence of both axes in order, with each group header's collapse state. Four things about it are load-bearing:

-
    -
  • It builds through GridLayout::with_frozen_records, never GridLayout::new. new does not apply prune_empty; a fingerprint built on it cannot see a pruned row, which is the entire class improvise-7kvw belongs to.
  • -
  • It hashes each record's coordinates as well as the axis entries, because records mode numbers its rows by position (gotcha 1): row_items is 0,1,2… whatever order the store is in. SortData changes neither the row count nor any label and still makes every position name a different record — and drill pending_edits are keyed by row index, so this is gotcha 18's disease exactly.
  • -
  • It hashes no page selection, deliberately. A page flip renumbers the grid only when prune_empty is on, and then with_frozen_records already sees it in the entries; hashing the selection as well homes the cursor on every [/] with pruning off, which PR #76 ruled is worse than the hole improvise-7kvw names. Both arms are tested. The temptation is to argue the selection belongs in a basis because the cells under it differ — true, and irrelevant: that is record_versions' job, and this hash answers the cursor.
  • -
  • The diff runs inside the effect loop, per model effect, for the same reason document_config does. At end-of-batch it overwrites a cursor a later effect in the same batch placed deliberately — [AddItem(measure), <place cursor>], which is how naming a measure at the bottom-right of a records grid works, sent the cursor home.
  • -
-

No effect declares any of this. improvise-9mo9's twelve reset_cursor calls are gone, along with the six inline no-op predicates (moved, drawn, real, existed) that existed only to stop those calls firing on a no-op, and the two helpers effective_items and item_is_drawn that served them. That table was the shape gotcha 24 names as the disease, and it had a hole (improvise-7kvw) the day it was written; six more were enumerated on the ticket before it was fixed.

-

Is every reframe also a reshape? No — measured, and the answer matters (improvise-9v0j). The relation between the two observations was separated by example and never stated as a property. One direction is the known asymmetry and the reason there are two: AddCategory of an empty category reshapes without reframing. The other direction was open, and the ticket guessed "probably yes, and if not, layout_version under-reports, which per gotcha 35 leaves a client wrong forever".

-

Probed 2026-09-21 by driving each ModelCommand through apply_command and reading cursor_epoch() and layout_version(): every command in reshaping_commands() moves both, but a ClearCell that prunes a row out of a pruning view moves the cursor epoch and not layout_version. That is not a defect in the observation — it is improvise-mcq4 working exactly as designed, since a cell write changes the data and not the view's configuration, and view_shape hashes configuration. So the candidate property test 9v0j proposes would fail, and writing it would be wrong.

-

What is left open is narrower and worth stating as a question rather than answered here: after such a write the grid has renumbered while layout_version has not moved, so a client still claiming the old layout tier is accepted. The record tier covers a write to the pruned record; it says nothing about a write to a surviving one whose row index moved under it. Whether that is a hole in ADR 001's middle tier or correctly delegated to the record tier is an ADR question, not a test to land quietly, and is filed as improvise-6nsd. Note the probe needed the right fixture to say anything: run against app_with_two_pruning_views, seven of the twelve commands are no-ops because that document has no Product category, and a table of false/false rows reads exactly like a finding.

-

Cost, measured rather than asserted (benches/reshape_observation.rs, the fixtures grid_projection uses): one SetCell batch goes from 172µs to 209µs at 400 cells, 7.09ms to 7.97ms at 18,000, and 35.2ms to 38.0ms at 90,000 — +8% to +22%, the largest share on the smallest fixture where the batch is cheapest. The batch is dominated by rebuild_layout and recompute_formulas, not by the fingerprint. The ticket required this measurement before landing, because "much cheaper than grid_of" is exactly the kind of claim that criterion exists to refuse.

-

The cost interacts with improvise-748f, and the fix is where the surprise was. 748f split the geometry fingerprint so a batch writing no cell stays O(config) off the held-key path. view_frame builds through with_frozen_records, which applies prune_empty, which walks cells — so for a pruned view the walk came back, four builds per batch. Measured, one CycleAxis batch with pruning on:

-
cellsfirst versionnowfingerprint off
400283.2µs167.0µs93.6µs
9,6007.34ms3.879ms1.967ms
18,00012.41ms5.752ms3.043ms
90,00091.69ms56.54ms28.52ms
-

4.1× down to 1.9× at the canonical size, back inside the 16.7ms budget.

-

The obvious fix bought nothing, and then bought a bug. Narrowing the fingerprint from every view to the active one looks like the answer and is not: a second view is usually a fresh one with everything on Axis::None, which costs nothing to build. The per-view multiplier was never the cost — two expensive builds were. What worked instead is that the opening basis is free: at batch start self.layout already is the active view's pruned layout, so opening_view_frame hashes it rather than building a second. It is guarded on drill_state.is_none(), because a drill freezes the record list and the held layout is then not what view_frame would build, and it is opening-use only because self.layout goes stale mid-batch (gotcha 23).

-

The narrowing was kept anyway, and that was wrong. It reads as the honest shape — the cursor's question is about one view — but a CellData write can prune a row out of a view nobody is looking at, and view_shapes cannot see it (config-only: it hashes the prune_empty flag, not which rows survive). So a bystander was left positioned on a grid that had lost a row, permanently, which is gotcha 24's worst case. A measurement showing a change is free is not evidence that it is correct, and the two questions were answered in the wrong order.

-

The arms are now split by the thing that differs: the LayoutGeometry arm keeps the cheap one-view frame, because view_mut() writes only the active view and the structural cases are already covered by view_shapes; the CellData arm pays for every view. Measured: 0% on the held-key path, +5.7% on an 18k-cell two-view SetCell, scaling linearly in view count.

-

The frame basis is keyed by view name, and it follows active_view. A batch that switches and then runs a model effect was comparing the new view's hash against the old view's — a near-certain mismatch, so a spurious reset. The first fix guarded the comparison on the names agreeing, which exempted the first geometry effect after a mid-batch switch from the check altogether (improvise-9c9o): under-reporting, gotcha 35's expensive side. So the re-base happens at the switch — in the loop, as soon as the name moves — and by the time the geometry arm runs the name and the basis agree by construction. Note what this did not need: SwitchView stays ViewOnly, because the basis is App's own bookkeeping and none of it is on the wire.

-

It costs one extra view_frame per batch that both switches and moves geometry — the re-base builds the incoming view's layout and the geometry arm builds it again. DrillIntoCell already pays one per SetAxis, so this is the batch that pays most, and the direction is gotcha 35's cheap one.

-

And writing the test for all this found the bug that was hiding it.

-

#The elsewhere path is only as good as the inputs it can rebuild (improvise-ac2z)

-

grid_in on a view the host is not on rebuilds a GridLayout from the Model and that view's config. Everything the host derived and cached stays the host's, and Model::formula_cache is exactly that: rebuild_layout fills it with recompute_formulas(&none_cats) for the host's view. A client on a view whose none_cats differ therefore reads formula values keyed by somebody else's coordinates — and since prune_empty drops what resolves to nothing, its formula columns vanish rather than showing wrong numbers.

-

prune_empty (improvise-t6db) was the first instance of this shape and was one line. This is the second, and it is not local: grid_in takes &App while recompute_formulas needs &mut Model, so it cannot be fixed where it is observed. Filed rather than bodged; candidates are on the ticket.

-

How it was found is the part to carry. The PR #78 hand-back reported this hazard as "argued and not observed", from a fixture whose stated setup — a category on Axis::None in one view and on Column in another — was absent: add_category defaults a new category to Column and nothing moved it. A negative result from a setup lacking its own condition reads identically to a real one, and is worse than silence, because it stops the next person looking. Setting the axis explicitly failed the property on the first run.

-

project::grid_in built a non-active view with GridLayout::new, which does not apply prune_empty — so a client on another view was projected rows its own config prunes, and its grid never lost a row, so the staleness was unobservable. Third instance of the new-versus-with_frozen_records trap. Fixed, and it broke no other test.

-

Two fixtures nearly hid all of this, and both are the same mistake. The SetCell benchmark shows the fingerprint at +2–5%, because 748f's own document_content walk dominates that arm. And the geometry benchmark, written first against the existing fixture, had prune_empty off — where with_frozen_records does not walk cells at all and the batch reads 17–176µs. A fixture that cannot reach the expensive branch measures the cheap one and reports it as the cost.

-

What is still guarded in one direction only, and is filed: nothing asserts that a batch which reframes a view is always one that view_shape also moves, or the converse. The separation is argued here and tested by example, not by property.

-

document_config is everything a LayoutGeometry effect can write that the file would carry: the categories with their items and groups, and every view's configuration whether it draws or not. It is what raises model_state.dirty, and it is a different hash from view_shape on purpose — the number format and a hidden ghost belong to the bytes on disk and not to the shape. The cell data is absent from both, because record_fingerprints answers for it and a whole-document hash per keystroke is not affordable (gotcha 41: Storage is whole-document).

-

ModelChange::CellData and ModelChange::WholeWorkbook still raise dirty off the declaration, because they write things no structural fingerprint can see: a formula, a sort order, a replaced document.

-

Both halves are read inside the loop, and the position is the property. The observed half compares document_config after each LayoutGeometry effect against the value it held after the previous one, rather than once around the whole batch. An after-the-loop comparison is correct about whether the document changed and still wrong, because it re-raises a flag Save cleared earlier in the same batch: [SetNumberFormat, Save] — reachable from :format ,.2f . w and from any Binding::Sequence pairing a geometry effect with a save — then reported a successful save and a dirty document, so the TUI drew "Saved to …" while :q refused, and the worker paid a whole-document OPFS rewrite. That is the failure class this whole section exists to close, reintroduced one level up. Guarded by a_batch_that_changes_the_layout_and_then_saves_ends_clean, over both arms. It still over-approximates there — a LoadModel that cannot read its path leaves the flag raised, costing one redundant write, where the other direction costs an edit. It used to be a separate MarkDirty effect that each command composed alongside its mutation, which improvise-kmgs's one-at-a-time derivation put SetCell on the wire without (improvise-gv9r): a derivation correct per element and wrong for a set.

-

Save is ViewOnly and clears the flag itself, so a batch that edits and then saves ends clean: the order in the loop is the order the effects were composed in. That is why the observation is in the loop and not after it.

-

layout_version is bumped after the loop, and that is not the same hazard: nothing clears it, and an aborted batch's effects never ran, so a comparison taken at the end sees only what did run. Neither arm of that is tested today; filed with the orchestrator rather than cited here, because an id that was never read back is worse than no id.

-

What the split does not reach, and is not a regression because the declared scheme did not reach it either: prune_empty makes a view's shape depend on the data, so a cell edit that empties a row prunes it without moving view_shape. The record tier covers the write; the shape tier does not see the reshape.

-

Both fingerprints sort every HashMap/HashSet they touch. That is not tidiness: an unsorted iteration order is per-process (gotcha 18), and a fingerprint that re-randomises every run would bump both counters on every batch.

-

record_versions cannot be read off the kind, because an EffectKind says that a batch writes cells, never which — so the records are read off the data itself, fingerprinted around the batch. Two consequences worth stating. A write that lands the value already there bumps nothing, which is the right answer: nobody's basis was invalidated by it. And a LayoutGeometry effect that drops cells will bump their records when it shares a batch with a CellData one — an over-bump, costing a rejection that need not have happened, which is the same asymmetry Dirty::of is deliberately sloppy in.

-

The case list is checked for completeness, not for length (improvise-26dm). layout_geometry_effects_actually_change_document_config walked a hand-built list under an assert_eq!(cases.len(), 13), and a count catches a case being deleted and never one being absent — the two are written identically. The adversarial review of PR #78 added a fourteenth pub struct declaring LayoutGeometry and the suite stayed green. doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness).

-

improvise-5ubs's answer — read serde's derive-generated variant list out of its unknown variant … expected one of message — cannot reach this one: an effect is a struct implementing a trait, so there is no derived enumeration of effects at all. But gotcha 38 says ModelCommand is derived from the model effects, with model_effects mapping each variant back to the effect it came from, so the list is now diffed against the LayoutGeometry effects reachable through ModelCommand, and the failure names the missing effect rather than a number. The two lists agree exactly today, which is itself the evidence the substitution lost nothing.

-

The right-hand side had to be bounded before it could be believed (PR #96, found independently by this branch's review and by ChatGPT Codex). every_model_command()'s doc comment claimed the enumeration was compiler-forced — variant_name matches exhaustively, so a new variant fails the build. That inference is invalid: the build fails in variant_name, and nothing obliged the value list to gain an entry. Deleting one from it failed this check and nothing else in the workspace.

-

So the list is now itself checked, by improvise-5ubs's mechanism: the_enumeration_names_every_variant_the_derive_declares diffs it against variants::declared::<ModelCommand>(), serde's derive-generated variant list. That makes the chain derive → every_model_command() → model_effects → case list, every link checked, and the whole thing is what the ticket asked for. variants was #[cfg(test)] pub(crate), so reaching it costs a test-support feature on improvise-protocol gating an optional serde_json — off in every shipped build, which is why the wasm gate is unaffected. The assertion message still names both directions, because a short right-hand side reads as a spurious case and invites deleting the left, degrading both sides together — the failure the ticket exists to close.

-

Its bound is stated rather than assumed: this closes the effects on the wire, and an effect declaring LayoutGeometry that no ModelCommand names is still invisible — which is precisely the probe variant PR #78 injected. That is an honest residual and not a fix, and it is the reason the ticket asked for a compiler-maintained enumeration of effects: none exists without a proc macro or inventory-style registration, and a fourth hand-rolled list in a fourth place is the disease. A second […; 13] array survives one function away in every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint and is filed, not smuggled in.

-

On abort, the loop resets abort_effects so the next dispatch starts clean and records batch_aborted where a caller can still read it.

-

#bump_changed_records

-

Bump every record whose raw cells read differently than they did at before. Both directions count: a record that gained cells, lost them, or had one rewritten has moved under anybody holding an older basis.

-

The diff collects into a set, not a list: a record present in both maps is named twice by the chain, and a rewritten record would otherwise count as two writes. (That bug survived one test run — gotcha 40.)

-

#dispatch_key

-

Resolve one keypress through the keymap and apply what it produces, reporting which widgets changed. The whole key path, with the projection half factored out: a host with no connected clients (the native TUI) drops the Dirty, and one with clients hands it to Sessions::project_all. ui::reduce is the named door onto this (improvise-gxi).

-
-

#ui/project.rs — turning what the server did into what each client is told

-

#grid_of's whole-grid cost, measured (improvise-i5tk)

-

The TUI builds its GridCache over the whole grid at offsets zero, so a cache position is an absolute position (gotcha 44). improvise-i5tk proposed narrowing that to the viewport, as the browser client already does, and made the benchmark acceptance criterion 1 — because "this can dominate TUI rendering" is exactly as unevidenced as "it is fine". benches/grid_projection.rs is that benchmark and it says no. One grid_of call, dense pivot grids, this machine:

-
typical                  100 x   4 =    400 cells  whole-grid  1.8-1.9ms   ratio 1.2x      col_widths 45%
-large                   1200 x   8 =   9600 cells  whole-grid  175-177ms   ratio 1.4x      col_widths 46-49%
-18k cells (gotcha 41)   3000 x   6 =  18000 cells  whole-grid  318-328ms   ratio 1.5x      col_widths 45-46%
-pathological            9000 x  10 =  90000 cells  whole-grid  4.8-4.9s    ratio 1.3-1.4x  col_widths 45-47%
-

Those are min-of-N figures, and the first version of this harness could not reproduce its own. It took the mean over a handful of iterations, which on a loaded machine is dominated by whatever else is running: review measured the same col_widths share at 8% and at 131% — a part taking longer than the whole containing it, printed flat because nothing checked. It also passed (comma, decimals) = (false, 2) where grid_of passes the active view's (true, 0), so it timed a call that never happens and reported it as a share of one that does. Both are fixed: the arguments come from parse_number_format on the view, the estimator is min-of-N, and a share above 100% prints UNSTABLE rather than a number that reads like a measurement. A benchmark that cannot detect its own impossible output is not evidence — the standard doctrine(check_the_checker) sets for a test, applied to a measurement.

-

ratio is whole-grid over a 45×12 terminal window: 1.2–1.5x, against a fix whose stated cost is teaching GridWidget that a cache index means something else and putting every scroll site in scope. That is the wrong trade, so i5tk is closed on the measurement rather than on the reasoning either way.

-

The reason the ratio is so low is the finding, and it is bigger than the ticket was. Two of the three whole-grid passes a frame performs are viewport- independent by construction, so no amount of windowing reaches them: ui/viewport.rs::compute_col_widths takes no viewport and walks every (row, col) twice — once for cell text, once for the column totals it must also size — and a column total has to sum every row including the ones a viewport scrolled past (gotcha 44). grid_of's own cell_display loop is the third pass and the only one narrowing would touch. So the whole grid is evaluated about three times per frame, every result discarded, and at 18,000 cells a keypress costs four tenths of a second. That is improvise-jmjz, and its fix is caching or one fused pass — not windowing, which this benchmark has now ruled out.

-

#One walk, and what it did and did not buy (improvise-jmjz)

-

ui/viewport.rs::grid_body is now the one walk. It evaluates each cell once and derives all three answers from that value: widen the column, add to the column's sum, and — if the position is inside the requested window — keep the CellValue for cell_display to dress. compute_col_widths survives as grid_body with an empty window, because the keypress path (App::apply_effects) wants widths and no cells, and two width rules would be the disease improvise-edp names.

-

Measured on the orchestrator's machine, 2026-09-21, before and after, with cargo bench --bench grid_projection:

-
casebeforeafter
typical (400 cells)1.840 ms0.687 ms
large (9,600)172.6 ms50.0 ms
18k (gotcha 41)294.4 ms95.2 ms
pathological (90,000)4.481 s1.320 s
-

About 3x, which is the number of passes removed — the shape the acceptance criterion asked for, rather than a speedup argued from the structure.

-

Three things to carry, none of which the ticket predicted.

-

The col_widths column of that benchmark now measures something else. It read 45–47% of the frame and now reads 64–82%, which looks like a regression and is the opposite: compute_col_widths is the fused walk, so its share rose while the frame it is a share of fell by two thirds. A ratio whose denominator moved is not comparable across the change, and the benchmark's own comment now says so — otherwise the next reader concludes the fix made widths worse.

-

The windowing ratio did not improve, and that is correct. It stays 1.2–1.4x, because the pass that remains is exactly the one a viewport cannot narrow: the widths and the totals are whole-grid by definition (gotcha 44). improvise-i5tk's decline stands, and the two findings are now cleanly separated — windowing was never the fix, and removing the redundancy never made windowing worth it.

-

The keypress path got the same fix for free. compute_col_widths is the walk now rather than two of them, so App::apply_effects' own width computation roughly halved on the same fixtures (134 ms to 68 ms at 18,000 cells). That is the 748f held-key path, and it was not the ticket's subject.

-

It is not solved, only three times cheaper. 18,000 cells is 95 ms, still 5.7 frames at 60fps, and the remaining cost is one genuine evaluation per cell. The next lever is caching across frames, which needs an invalidation story the record_versions/layout_version tiers could plausibly supply.

-

The guard is a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths: with one walk serving three consumers, a window that leaked into the other two would silently make a column total the sum of the visible rows, or size a column by what happens to be on screen. Two faults, then, and its first fixture could see only one — see context/test-evidence/command.md, because the way it was blind is more instructive than the fixture that fixed it. Briefly: the widths half was asserted by comparing grid_body against compute_col_widths, which is grid_body with an empty window, so windowing degrades both sides identically. The same function twice is not a check, and it reads exactly like one.

-

The benchmark carries test = false: it takes seconds by design, and the suite's rule is that no single test binary exceeds two of them.

-

#What the module is (improvise-cqq)

-

The producer half of the per-widget event architecture (improvise-rkeq): improvise-protocol says how a client folds a Projection, and this module says which projections the server emits. Nothing here renders, and nothing here holds a transport — a websocket or a worker port calls Sessions::project_all and writes the result out.

-

Three decisions shape it.

-

What changed is decided by diff, not by the effect that changed it. An effect knows its EffectKind but not which cells it touched, and asking every effect to report its cell set would be a second, silently divergent description of what it did. So the server rebuilds the desired state of each widget and compares it with the mirror of what that client was last told. The effect kinds still earn their keep: Dirty turns a batch's kinds into the set of widgets worth rebuilding, so a ViewOnly batch costs nothing, and a cell edit outside a client's viewport produces no traffic for that client because the desired viewport is unchanged.

-

Only the grid gets a mirror. The other widgets' updates replace their cache wholesale (Projection::Formulas is the whole list), so "has it changed" and "send it" collapse into one decision, which Dirty already made. The grid's Cells update is positional and incremental, so it needs to know what the client already holds.

-

A viewport is the client's report, not the server's guess. The scroll offsets are already in the command stream and reach the server's mirror through reduce_view, so the server reads them off the mirrored ViewState. The extent — how many rows and columns fit — is a fact about a terminal or a browser window that no Command carries, and inventing one would break decision(improvise-cqi, command_set_derived_not_invented). It is set on the session by the host instead (ClientSession::set_extent).

-

The emission order is load-bearing: a GridUpdate::Cells that overtakes the labels naming its positions is dropped by the consumer, so grid_updates emits labels first and diffs the cells against the frame those labels will leave behind (gotcha 33).

-

#Dirty — a gate, not a description

-

Which widgets a batch of effects could have changed. The projections themselves are computed by comparing current state with what the client holds, so a Dirty that over-reports costs work and never correctness. Under-reporting is a correctness bug, which is why Dirty::of matches exhaustively on EffectKind — a new ModelChange variant fails the build.

-

#Dirty::of — the derivation (gotcha 35)

-

This section is the record of the derivation that the doc comment used to be. Re-read it when an effect is reclassified. It has gone stale once already, claiming AddCategory was CellData after it had been moved.

-

Read off the effects that carry each kind, not guessed:

-
    -
  • ModelChange::CellData is worn by AddItem, RemoveItem, AddFormula and RemoveFormula as well as SetCell, so it reaches the category panel and the formula panel, but no view's axes move under it.
  • -
  • ModelChange::LayoutGeometry is worn by SetAxis, CreateView, DeleteView — and by AddCategory/RemoveCategory, which rewrite every view's axis map (gotcha 3) — so it reaches every axis-bearing widget and the formula list, because Model::remove_category ends by dropping every formula that targets the category it removed.
  • -
-

The formula list is the one entry where over- and under-reporting are not symmetric in cost: a LayoutGeometry batch that leaves the formulas alone pays one redundant Projection::Formulas, while one that dropped a formula and said nothing leaves the client's FormulaCache wrong forever.

-

The LayoutGeometry arm is spelled out rather than written as EVERYTHING: the two coincide today, but they are derived from different effects and an entry added for one must not silently arrive for the other.

-

#grid_in's elsewhere path and the inputs it must rebuild (improvise-ac2z)

-

grid_in projects a view the host may not be on, and doctrine(the_elsewhere_path_is_only_as_correct_as_the_inputs_it_can_rebuild) says to enumerate the inputs and ask of each whether it was derived for the caller or for the subject. Two have been found that way. prune_empty was the first (improvise-t6db): GridLayout::new does not apply it, so the fix was with_frozen_records. Model::formula_cache is the second, and it was worse, because it cannot be fixed where it is observed — grid_in takes &App and recompute_formulas needs &mut Model.

-

rederived_for answers it by copying: where the target view's none_cats differ from the host's and the document has formulas, it clones the model and recomputes for that view. The three conditions are what keep the clone rare — the common case is a client on the host's own view, which pays nothing, and a formula-free document pays nothing ever. It is an interim: keying formula_cache by none_cats, so one Model holds more than one view's derived values, is the durable answer and is an improvise-core change.

-

The list is not closed. Anything else rebuild_layout computes for the host's view and grid_in then reads is the next instance, and nothing enumerates them — which is the doctrine's whole point.

-

#grid_of — the extent is the whole grid

-

GridExtent carries the whole grid, not the window: it is what a client clamps its own cursor against (improvise-bpq2, gotcha 47). It is read off the layout rather than off the projected ranges, which are already clipped.

-

#axis_frame — one axis's frame, windowed

-

Three edge rules, and every one of them is about a group header, because a header occupies a row without consuming a data index (gotcha 1's cousin: the data index counts items, the visual position counts entries).

-
    -
  • One header above the window comes with it. A column window opening inside Q1 must still be able to say the column is in Q1 (AxisFrame::group_of), so the nearest header above the first in-window item is carried — and only that one, or the payload grows with the groups a deep scroll passed. AxisFrame::body_from then declines to paint it, which is the cut GridLayout::data_row_to_visual always made. In the loop, entries.is_empty() is true exactly until the first in-window item lands, which is what makes the compaction correct.
  • -
  • Consecutive headers inside the window all come. [North, Q1 (collapsed), Q2, Feb] is a real shape — a collapsed group emits its header and no items — and only the nearest wins group_of, but dropping the far one would lose the only row saying those items exist. (Seen, not yet acted on, in a browser: z is Upstream and web/app.js puts no listener on a group header.) Above the window it is the other way round: nothing there is painted, so only the nearest is kept.
  • -
  • A trailing header comes only when the window reaches the last item. A collapsed group at the bottom of the grid has no items to hang off and is still a row the user must see; but a window scrolled to the middle must not be handed the bottom of the document.
  • -
-

#column_totals

-

Each visible column summed over every row of the grid, not just the rows in the viewport. That is why it is projected rather than left to the client, and it is the same sum the TUI's total row has always drawn. Empty in records mode, where a column is a measure or a coordinate and a column sum means nothing, and empty for a grid with no rows or no columns — an empty strip says "no totals", which is a different statement from a strip of blanks (gotcha 33).

-

#cell_display

-

One cell as the client draws it. The server is the side that knows whether a value was a number, so it says so rather than letting the client re-derive it from the text — and by the same argument it is the side that knows whether the cell is settable, so it says that too (ADR 002, cell_id).

-

Records mode resolves through the frozen snapshot, which yields display text and nothing else; numeric-ness is read back off the text, the one place this module infers rather than knows.

-

edit_text is the unformatted value, which is what the TUI's own display_value_at_cursor seeds its edit buffer from. Projecting it rather than letting a client un-format text is the rule from gotcha 47: where a client cannot compose the command, enrich the projection.

-

#cell_id — stored cell or aggregate

-

What the cell at (row, col) is, expressed as the surrogate the client echoes back. The fork is not invented here. EditOrDrill (cmd/mode.rs) already makes exactly this decision on every keypress in the TUI, and asks it the same way: a cell is aggregated when a regular category sits on the None axis. Virtual categories are always on None in pivot mode and imply nothing, and a records-mode cell is never aggregated whatever the axes say — which is a property of the layout, not of a mode flag.

-

In records mode it uses the row's coordinates, never the synthetic (_Index, _Dim) key cell_key returns there: that one carries positions, and a position is what ADR 002 rejected _Index for.

-

RecordCol::Category, NewMeasure and NewCategory are not cells to write into and get no id. Editing a category column renames a coordinate across every measure cell in the row (gotcha 1) and the +/+cat columns create a measure or a category; all are different intents from SetCell, so none gets an id it could be composed with.

-

#category_rows

-

The categories the category panel draws: every category, virtual ones included, with their items and their axis in the active view.

-

category_names, not regular_category_names (improvise-vjms): the TUI panel has always listed _Index/_Dim/_Measure and the browser panel is to list the same rows, so the filter belonged in neither.

-

effective_item_names, not category(name).items: _Measure's items are explicit items ∪ formula targets (gotcha 7), and the raw list omits every target. That is the whole reason this is one function and not two.

-

try_axis_of, not axis_of: a view that has never seen a category has no entry for it and axis_of panics. Unseen is None, which is what the view will show it as.

-

#The four whole-cache accessors, for a host that holds the App

-

A host that renders in the same process as the authoritative App — the ratatui binary today — has no transport to fold a projection stream out of, but must draw the same widgets from the same data as a remote client. These four give it each widget's cache outright. They are the same derivation document_projections sends, spelled once: a projection is built from the cache, so an in-process renderer and a remote one cannot disagree about what a widget holds.

-

There is deliberately no function returning all of them together, and no struct to hold them: that aggregate is the shape improvise-rkeq rejected, and a caller that wants several calls several.

-

Their use by the TUI is temporary. The native binary calls these once per frame, rebuilding from the App rather than folding updates, so that widgets can migrate off &Model/&View one at a time while the binary split is in flight. The per-frame rebuild is what goes away when the TUI becomes a client; the functions themselves stay, because the server needs them. Nothing in src/ should come to depend on when they are called.

-

#document_projections

-

The projections for every widget whose content does not depend on a client's viewport. Each of these updates replaces its cache wholesale, so dirty is the whole decision.

-

#wizard_of / wizard_cache (improvise-764)

-

The import wizard as the widget draws it, or None when no import is open. This is where the wizard's improvise-io types stop. FieldKind, DateComponent, ImportPipeline and the built Workbook are all read here and none of them leaves; what comes out is WizardCache, which names only strings, booleans and its own two enums.

-

Unlike every other *_of in the module it feeds no Projection: the wizard is session-local (gotcha 30) and broadcasting it would draw one client's dialogue on every client — the rendering-side twin of the bug improvise-tom1 removed from ModelCommand.

-

wizard_cache is split out from wizard_of so it can be exercised against a wizard driven through its steps, with no App in the way. The App half is one Option::map; this half is where every improvise-io type is spent.

-

DATE_COMPONENTS lists the date components the wizard offers, in the order it offers them. The order is load-bearing: ImportWizard::date_config_at_cursor counts these same rows, so the cursor the cache carries indexes this list.

-

summarize_workbook is the one-line description of the workbook awaiting confirmation. It lived in the widget, which is how a renderer came to be calling regular_category_names() and measure_item_names() on a Model.

-

#grid_updates — the diff, and what it diffs against

-

The updates that carry mirror to desired. Labels come first and cells last, because the consumer's fold is not commutative: a Cells update that overtakes the labels naming its positions is dropped. The cells are therefore compared not against mirror but against the frame the label updates will leave — which is also what makes the result exact rather than merely sufficient, since a relabelled grid keeps the cells that still fit.

-

The totals are diffed against that same framed mirror (improvise-hkt6, found by Copilot). apply blanks the total under a relabelled column (gotcha 33(b)), so comparing against mirror missed exactly the case where the label moved and the number did not: no Totals went out, the client blanked its own, and ClientSession::project_diff's self.grid = desired left the server believing it was still there. A client cache wrong forever, which is the direction Dirty::of is deliberately sloppy to avoid (gotcha 35).

-

Only the totals. The widths stay on mirror: folding the labels re-fits them too, so diffing widths against the fold would suppress the ColWidths that a_client_on_a_socket_is_seeded_and_gets_its_edit_back asserts a reframe carries — redundant, but it is gotcha 33(c)'s standing evidence and not this ticket's to spend.

-

#ClientSession

-

One connected client as the server sees it: the server's mirror of that client's ViewState — maintained by folding the same commands the client folds, never by shipping state — and the grid cache the client is believed to hold.

-

basis is the basis this client was last told (ADR 001, improvise-fkfg). It is mirrored for the same reason the grid is: it is diffed, so a client hears about it exactly when it moves. Without publication the check would be unsatisfiable — nothing else in the stream carries any of the three tiers, so a client could only ever forge one, and ADR 001 does not say where a client gets its basis from. That omission is the one place the ADR met the code and was wrong.

-

fold folds a client→server command into this client's mirrored view slice — the same function the client runs, so the two cannot drift. It is private, and reached only through Sessions::apply: a command with a document half cannot be honoured from inside one session, and a door that takes every Command while honouring only some of them is a convention every transport author would have to be told about.

-

project is everything this client must be told, given what the last batch changed. The grid is emitted when the document dirtied it or when this client's own viewport moved — a scroll is ViewOnly and reaches no other client, but it still changes what this one can see. A mirror seeded against a document that has since been replaced is reseeded instead of diffed: its cursor map is keyed by the outgoing document's view names (gotcha 24), so every position in it means nothing here. The check lives on this path rather than on a hook because the effect that installs a workbook cannot reach the session list, and a hook nothing can call is a convention, not a mechanism.

-

basis_update is the basis to publish, if it has moved since this client last heard. It is emitted after the grid updates in the same batch, and that order is load-bearing: the record counters describe the cells those updates carry, so a basis arriving first would let a client claim a version for a value it had not yet been shown. It is the mirror image of gotcha 33's rule that labels precede the cells they frame. The records published are the ones this client's grid names, which is exactly the set it can address a write to (ADR 002); publishing the whole document's counters would be both larger and no more useful, since a surrogate the client has never been projected is one it cannot compose a SetCell for.

-

reseed points the mirror at the app's current document, holding nothing, and returns the projections that carry it back — a compaction of the stream so far, not a replay of it. That is what connect does for a new client, and what a document swap does for every existing one.

-

#Broadcast — the audience is part of the message

-

The two cases mean different things and a host cannot infer which from the command. Making it a field of the thing being sent is the same move as Applied being an enum: a host must name the audience to get at the command (doctrine(an_invariant_is_made_unrepresentable_not_validated)).

-
    -
  • To(SessionId, _) — one named client. A correction of something only that client's UI holds, which no other client is affected by. It has two cases, and they are why it names a session rather than "the asker": a switch to a view the document has not got, which is the asker (improvise-6c6q), and a view deleted out from under whoever was on it, which is not. ToAll would double-apply the first on the accepted path and move views nobody asked to move in the second.
  • -
  • ToAll — every client, the asker included. Not a correction but a consequence: the document did something that invalidates a fact each client holds privately, and the asker holds that fact too. A Command::Model is never folded optimistically — a client cannot predict what the document will accept — so the asker is owed it on exactly the same terms as everyone else (improvise-gvnb, gotcha 36).
  • -
-

#Applied — an enum because a document swap is not a diff

-

A command that replaced the document obliges the host to do something categorically different, and an obligation stated in a field is one a host can read past. Matching is how it is made unavoidable: a host that wants the Dirty has to name the other cases to get at it.

-

The distinction is not cosmetic. The server's own mirrors self-heal — ClientSession::project reseeds the moment the generation moves — but ViewState::cursors lives on the client, keyed by the outgoing document's view names, and only a fresh Snapshot replaces it (gotcha 24). Before Command::Model existed, a document swap could only come from this host's own keypress; now another client can ask for one, and the host that must send the snapshots is not the host that asked.

-
    -
  • Diff { dirty, broadcast } — the document is still the same one. dirty feeds Sessions::project_all. broadcast carries what clients must fold into their own view slices: the server moved their mirrors itself, but a client's slice lives on the client, and the only thing keeping the two from drifting is that both run reduce_view over the same command. It is a Vec rather than an Option because one command can owe two relays with different audiences, and the two are not mutually exclusive by construction — only by which variants happen to exist today. An Option would have had to drop one silently.
  • -
  • Rebased { snapshots } — the document was replaced and every connected client owes a bootstrap. There is no Dirty and nothing to broadcast: each Snapshot already carries a whole view slice and the seed that reproduces the new document, so a diff against the old one would describe nothing that still exists. The snapshots are addressed by SessionId because a transport delivers them one connection at a time.
  • -
  • Rejected { reason } — the batch was composed against a picture of the document that has since moved, and nothing was applied (ADR 001, improvise-fkfg). A normal outcome, not an error: the client already holds the current state through projections, and what it owes the user is to keep the typing, say what moved, and offer force where force is coherent — which is what Rejection distinguishes. It is a third case for the reason the enum exists at all (gotcha 39): a host that wants the Dirty has to match, and matching is what stops "the server refused this" being silently read past. The rejection is addressed to the one session that asked, because unlike a document change it is nobody else's business — nothing happened.
  • -
-

#Sessions::connect

-

Attach a client and hand it the bootstrap Snapshot: a fresh view slice pointed at the document's active view, plus the seed it folds to catch up. The snapshot's view is the mirror's, cloned — not a second value built to the same recipe. reseed is what decides what a freshly seeded mirror holds, and a client told anything else starts out disagreeing with the server's picture of it.

-

#Sessions::apply — the one door (gotcha 36)

-

It takes the authoritative App because some commands are not one client's business alone. Command::Model is a document mutation by definition and goes to apply_model, never to a mirror: the model is shared, so what the other clients are told is the Dirty it produced, not the command itself. SwitchView has a document half — the workbook's active view and the client's must move together, and App::switch_to_view is one of the only two writers allowed to touch either (gotcha 29) — so it is routed through apply_command rather than folded into the mirror. Folding it into the mirror alone left the session naming a view the workbook had never switched to, and the next project() built that client a grid out of the other view.

-

Everything else has no document half and stays this client's: it never touches the App's own view slice, because the model is shared and the UI is not.

-

Lockstep on the active view is today's answer, not the last one: improvise-g0f allows two clients to sit on different views, and improvise-jytm owns that split. What this shape fixes survives it — the mirror and the state the projection is built from move in one call, so whichever way g0f lands there is one place to change.

-

Lockstep is a property of every session, not of the one that asked. grid_of builds every client's grid from the workbook's active view, so a session left naming the outgoing one is read under a stale cursor key: it is projected the new view's rows positioned by the old view's scroll, which is gotcha 24's cursor-vs-geometry mismatch arriving through another client. So the accepted switch reaches every mirror, and Applied::broadcast carries it on to the clients whose own slices must fold it too.

-

The basis check is the first thing, before the dispatch, and it is a guard rather than a branch inside the apply: a batch whose basis moved is never applied, not even partially (ADR 001). It is Checked::against that runs it, because the answer is what buys the Checked every mutation below needs — the check is not a step this function could forget (improvise-ondj).

-

Note: at this commit Sessions::apply has no production caller; hosts go through apply_checked. See the ui/reduce.rs section.

-

#Sessions::apply_checked

-

The same door, for a command whose answer is already in hand. A frame is a sequence of commands and ADR 001's atomicity is the frame's, so Host::commands answers for all of them with a CheckedBatch and then applies them through here — re-asking per command would reject the later ones for staleness their own siblings caused (improvise-955n). It cannot return Applied::Rejected: the refusal happened, if it happened, before the batch was built.

-

#apply_switch

-

Which view a client is on is that client's (improvise-jytm, improvise-g0f): the model is shared and the UI is not. So this takes a SessionId — it is addressed to exactly one session, which is the opposite of what it once was — moves only that session's mirror, and dirties nothing.

-

The one thing it still asks the document is whether the view exists. A slice keys ViewState::cursors and is read by grid_in, so a mirror naming a view the document lacks would index nothing; what is adopted is therefore what was accepted, which on refusal is the view the session was already on.

-

The audience follows from that, and it is conditional. On the accepted path the asker folded the right thing and nobody else is affected, so nothing is relayed. On the refused path the asker — and only the asker — folded a name the document turned down, so it gets Broadcast::To(id, …). ToOthers was the pre-jytm shape and had the mirror-image hole: it told every client but the one that was wrong. A blanket ToAll is equally wrong here, and for a reason that survives jytm: the asker really did fold, so ToAll double-applies on the common path.

-

Note what is not here any more. adopt_active_view pushed the accepted view into every mirror; under a per-client active view that is precisely the bug, so it is gone along with the Broadcast::ToOthers variant it was the only producer of. A mechanism whose reason has gone is deleted rather than left.

-

#apply_model (improvise-kmgs)

-

It goes through the same apply_command, which runs the effect the command was derived from and reads the Dirty off that effect's own EffectKind — there is no second classification here that could disagree with gotcha 35's table.

-

It takes no SessionId, and since improvise-jytm that is the whole distinction from apply_switch: a document change is addressed to no one session, a view switch to exactly one. No model mutation moves anybody's active view any more — ViewBack/ViewForward left ModelCommand with jytm, because the stack they pop is one client's history (the ApplyAndClearDrill argument, gotcha 38), and a LoadModel that installs a different document bumps the generation and so lands in the rebase arm, where every client gets a whole fresh slice.

-

Two things are left to relay. adopt_cursor_epoch's reset is Broadcast::ToAll: nothing was folded optimistically, so the asker is not a client being corrected but one being told, exactly like the others. And adopt_deleted_views is the case a per-client active view creates — see below.

-

#adopt_deleted_views (improvise-jytm)

-

A DeleteView can take away the view a different client is sitting on. Its mirror then names nothing, App::resolve_view falls back to the document's default, and that client is projected the default's grid while its own slice still names the view that is gone — the server and the client disagreeing about which view keys the cursor map, which is gotcha 24's mismatch arriving through somebody else's keystroke.

-

Before the split this could not happen: Workbook::delete_view moved the document's active view and adopt_active_view pushed the result into every mirror. The split removed both, so the case has to be answered on its own.

-

It is observed, not predicted — the check is "does this mirror still name a view the document has", not "was this command a DeleteView" — for the reason cursor_epoch beats matching on CycleAxis (gotcha 24) and document_generation beats matching on LoadModel (gotcha 39): a table of which commands can remove a view would be a second description of Workbook, free to disagree with it.

-

Each affected client is told with Broadcast::To, and the others are not: a client on another view lost nothing.

-

The basis was answered for by Checked::against before the dispatch that reached here. That it is a type and not a line at the top of the function is what stops a second mutation path from being written without it (improvise-ondj). stale_basis is the only decision, and it has no argument that suppresses it — ADR 001's force is a fresh batch under a fresh basis, composed by the client, not a flag that walks past it.

-

Rebase is decided by the generation, not by the variant. The three WholeWorkbook commands can land here, and whether they actually replaced the document is not a property of the variant: a LoadModel that fails to read leaves the document alone, and an AcceptWizardModel with nothing built does nothing at all. So the question asked is the decisive one — did the generation move (gotcha 24) — and the rebase is done here rather than handed to the host as an instruction, for the same reason apply_switch adopts the accepted view itself: the only enforceable version of "you must also do X" is doing X.

-

#adopt_cursor_epoch (improvise-gvnb)

-

If the batch renumbered the grid, invalidate every mirror's cursor and say so on the wire.

-

Two halves, and neither works alone. Resetting only the mirrors would leave the diff believing each client already holds origin-positioned content, so nothing would be sent and every client would stay stale permanently rather than until its next scroll: a partial fix strictly worse than none. Broadcasting only would leave the server projecting each client a window it is no longer looking at.

-

The audience is Broadcast::ToAll, and the asker is not an exception. Its mirror was reset here like everyone else's, and it folded nothing optimistically, because a Command::Model is not something a client can apply to itself.

-

What is not broadcast is a position: another client's transpose must not move where I am looking — that is UI and it is mine (improvise-g0f). It may only declare that where I was looking has stopped meaning anything, which is a fact about the shared geometry.

-

#Sessions::rebase

-

Re-attach every session to a document that has just replaced the one they were connected to, and hand each client the bootstrap it needs. The server's own mirrors are reset by ClientSession::project the moment the generation moves, so this is not what keeps the server honest. It is the other half: a client's view slice lives on the client, and only a fresh Snapshot tells it to drop a cursor that names a view of a document that no longer exists.

-

apply_model calls it itself when a command moved the generation, and hands the result back as Applied::Rebased. It stays public because a host also replaces the document by its own keypress path, where no Applied is produced.

-

#fresh_view

-

The view slice a newly-connected client starts from: empty, but pointed at the document's active view, because that is what keys its cursor map (improvise-8uz, gotcha 29).

-
-

#ui/host.rs — the session loop

-

#What the module is

-

Messages in, addressed messages out. A websocket is awkward to test, so nothing that decides anything lives in one. Host is a pure-ish state machine over the authoritative App — it takes one client's frame and returns the frames that owes every client — and crate::transport is the shell that moves bytes in and out of it. Every test in this crate drives Host directly; the socket is exercised by running the binary.

-

One App per document, many clients per App. The ticket (improvise-q08) asked for one App per connection, "no shared authoritative state across sessions yet". That premise no longer holds (doctrine(check_the_ticket_premise)): Sessions exists precisely to hold many ClientSessions against one authoritative App, apply_model invalidates every mirror's cursor rather than the asker's (doctrine(lockstep_binds_every_session_not_the_asker), now scoped to the document half since improvise-jytm made the active view per-client), and Applied::Diff::broadcast exists only because other clients must hear what this one did. An App per connection would make all of that dead code and give two browser tabs on the same document two documents.

-

Nor is it one App per server. improvise is a multi-document app, and the unit of authority is the document: a Host is one App, one Sessions, and the clients attached to them. That grain is what Sessions was already built for — it never owns the App, every method takes it as a parameter — and it is what makes concurrency scope for free: sessions editing independent models cannot conflict, and the basis versions ADR 001 reasons about live on the App, one sequence per document.

-

So a second document is a second Host, and hosting several means a registry of them keyed by document id. That registry is improvise_ws_server::registry (improvise-j31x), and it cost this file nothing: a Host was always a value rather than a singleton, and handle is already addressed to one document by virtue of being a method. What the registry adds is the only thing a Host cannot supply — a server-wide address, since SessionIds are minted per Sessions and every document's first client is SessionId(0).

-

What sharing one document costs is honest and stated: there is no per-client permission, no conflict resolution beyond last-writer-wins, and a client that can connect can rewrite the document. ADR 001 owns where that goes next; this host is the localhost single-user case that ADR describes the starting point of.

-

#Addressed

-

One outbound frame and the connection it goes to. The host addresses by SessionId and knows nothing about sockets; the transport owns the map from a session to a connection.

-

#Host

-

One authoritative document and every client attached to it — the unit a multi-document server keys its registry by. A Host is a value, never a global, so hosting several is a map of these and not a change to anything inside one.

-

It is deliberately not Debug: App is not, and a Host printing the whole document into a log line would not be wanted even if it were.

-

#Host::app_mut

-

The authoritative App, for work the host itself initiates. Deliberately narrow in intent, and not a second door: a client's Command must go through Host::handle, which routes it into Sessions::apply — the one place a basis is checked and the mirrors are kept in lockstep (gotchas 36, 43). What this is for is the work a host does on nobody's behalf, where there is no session to address and no claim to check: the browser worker writes the document back through the Save effect after a batch dirties it, because saving is EffectKind::ViewOnly and so reaches no wire vocabulary.

-

#Host::connect

-

Attach a client that has reported an extent of rows x cols, and hand back its bootstrap. Total: there is no way for a connection to fail here, which is why the transport reads the extent off the first frame before calling. Nothing is owed to the other clients — a new connection changes no document state and moves no mirror but its own.

-

#ClientFrame::Key answers nothing, and that is settled (improvise-bpq2)

-

Resolving a keypress here needs reduce_key against the authoritative App, which emits no Command a client could fold — so the two would diverge, which improvise-worker-server's a_key_resolved_on_the_host_moves_state_no_client_can_hear demonstrates. bpq2 closed that by moving resolution to the client, so a key a client can answer never arrives as a Key frame at all, and one that does is a binding nothing on this side could answer without diverging. Growing the set is a matter of enriching the projection until the client can compose the command itself — see improvise-wasm-client::keys, which carries the list of what is still upstream. (Gotcha 47.)

-

#Host::save (improvise-368h)

-

Write the document back, because a client asked.

-

No session state moves and nobody else is told, which is why it does not go through Sessions at all: saving changes no document state and no client's view slice, so there is no mirror to adopt and nothing to broadcast. What moved is bytes in this host's storage — the same fact that makes effect::Save EffectKind::ViewOnly and the reason the ModelCommand derivation could never reach it (gotchas 3, 38). Success is silent, on the same terms as any accepted command that dirties no widget.

-

Both refusals are the host's to answer, not Checked's: neither is about a basis. The missing path is decided before anything runs, because a save that cannot land should be refused rather than attempted and then reported. The failed write can only be found by trying, and Save signals it by aborting its batch (gotcha 21) — the batch here is exactly [Save], so an abort is that failure and nothing else. Reporting a failed write as success is the thing this exists to stop.

-

#Host::resize

-

A resize dirties no document state at all — but it moves this client's viewport, and ClientSession::project emits the grid when the viewport moved even under Dirty::NOTHING. So the fan-out is the same call as everywhere else, with nothing dirty. A frame for a session that has already gone is dropped: there is no connection left to answer.

-

#Host::commands — answered whole, then applied whole (improvise-955n)

-

Order is why a batch exists at all: one keypress resolved on a client is SetSelected plus the scroll offsets that follow it, and a host that folded the selection but not the offset projects that client the wrong window.

-

It used to apply in order and abandon at the first rejection, which left a hole ADR 001's "not even partially" does not have: a refused frame's earlier commands stayed applied. The check has moved from the command to the frame, so there is no longer a moment at which part of a frame has landed and the rest has been refused — the refusal, if any, happens before anything is applied, and Sessions::apply_checked cannot produce one. CheckedBatch carries what a batch's basis means and the one residue this does not close (clause 4).

-

#Host::command

-

The one door (gotcha 36), and the match that follows it is the mechanism (gotcha 39): asking for the Dirty means naming the case where a Snapshot is owed instead.

-
    -
  • Rebased — the document was replaced. Nothing to diff against and nothing to broadcast; each snapshot already carries a whole view slice and the seed that reproduces the new document.
  • -
  • Diff — the broadcast precedes the projections, so a client's view slice is in the state the grid it is about to be sent was built from. Dropping it for the bystanders leaves their slices naming the outgoing view while that grid comes from the incoming one — a stale mirror arriving through somebody else's keystroke (gotcha 36), which is the bug PR #10 was found to have. Who each item reaches is carried by the item, not decided here.
  • -
  • Rejected — unreachable through this door since improvise-955n, since a frame is refused before any of it is applied, by commands. It is still matched because Applied is the shape Sessions::apply answers in and a host that wants the Dirty must name every case (gotcha 39). The answer is the same one commands gives: nothing was applied, so nothing is owed to anybody else, and the asker is told only that its batch did not land and which class that was.
  • -
-

#Host::relay / Host::project

-

Who hears a relayed command is the relay's own business, not the host's guess: Broadcast names its audience and the match is what makes the host honour it (improvise-gvnb).

-

project drains the outbound projections — the ticket's "projection queue", which turned out to be a call rather than a queue. Clients with nothing to hear are omitted by project_all and so get no frame.

-
-

#ui/viewport.rs and ui/cat_tree.rs — the residue of two shared derivations

-

#viewport.rs

-

MAX_ROW_HEADER_W/MIN_ROW_HEADER_W moved to improvise-protocol with the rule that clamps to them (improvise-1125): a browser sizing its own row header must reach the same numbers, and it cannot depend on this crate.

-

compute_col_widths computes adaptive column widths for pivot mode (header labels

-
    -
  • cell values). Header widths use the widest individual level label, not the
  • -
-

joined multi-level string, matching how the grid renderer draws each level on its own row with repeat-suppression.

-

compute_row_header_width is the total row-header width for a layout. The rule itself is improvise_protocol::header_level_widths, which is where both front ends reach it (improvise-1125). This is the one caller that holds a GridLayout rather than a projected frame — App::visible_cols, deciding how many columns fit — and it is on the keypress path, so it hands the rule borrowed tuples rather than building a frame and cloning every row. It does not reach for a View either, because a collapsed flag cannot change a width.

-

#cat_tree.rs

-

The derivation itself moved to improvise_protocol::viewmodel (improvise-edp), because the browser client needs it and can only reach the protocol crate. What is left here is the path that call sites in this crate and in the TUI already spell, and the one convenience that needs an App: building the cache before flattening it.

-

build_cat_tree builds the flattened tree straight from the model, for a host that holds the authoritative state rather than a folded cache — the same shortcut project::category_tree_of is for the widgets.

-
-

#command/keymap.rs — the dispatch/registry half

-

#What is here and what is not

-

The table — KeyPattern, Binding, Keymap, ModeKey, TextEntry, PanelKeymap and Keymaps::default_keymaps() — lives in improvise_protocol::keymap, so the browser client looks up the same bindings the terminal does rather than mirroring them. Read that module's docs for why.

-

What is here is what a thin client cannot have: resolving a bound name into Effects through a CmdRegistry, and the SetTransientPrefix effect that arms a prefix key. This file is the authority on that split — the doc review named it as such — so a claim about where the keymap lives belongs here and in improvise-protocol::keymap, and nowhere else.

-

#SetTransientPrefix (improvise-jc8)

-

Arms a prefix key, so the next keypress is dispatched against that prefix's sub-keymap. It records the address of the prefix — the key that opened it — rather than the sub-keymap itself. KeymapSet already owns every keymap, so holding an Arc<Keymap> in ViewState would only put a non-portable type into what is meant to become portable, shared state (gotcha 13).

-

#effects_of — why a Sequence flattens, and why it stays that way (improvise-0hb)

-

The one place a keymap lookup becomes a mutation, and the reason Binding itself can be registry-free data.

-

Every step is executed against the same pre-dispatch ctx, and the effects are concatenated into one vec that App::apply_effects runs as one batch. So a later command cannot observe an earlier one's effects — only a later effect can. improvise-0hb proposed closing that: apply effects so far between steps, rebuild the layout, build a fresh CmdContext, dispatch the next. The design pass says no, on three findings.

-

It would silently reopen improvise-8tk, a P1 data loss. apply_effects resets abort_effects at entry and again when it breaks, because the flag is per batch. Applying between steps therefore dispatches step 2 into a fresh batch with the flag cleared, so the Quit after a failed Save would land — the exact silent discard 8tk fixed. Gotcha 21's claim that the abort "carries across sequence steps" is true because of this flattening, not in spite of it. It was also untested until recently: no shipped Sequence has a first step that can abort, and ZZ is a prefix onto the single wq command. app.rs::a_sequence_whose_first_step_aborts_does_not_run_the_second is the guard, and it fails when the abort is made per-step.

-

Almost nothing would benefit. Of the shipped Sequence bindings, all but one come from TextEntry and are either [<commit>, clear-buffer <buf>] or [clear-buffer <buf>, enter-mode <exit>] — clear-buffer and enter-mode take their operand as a literal arg and read nothing from ctx, so a refreshed ctx would change no outcome. The single genuine composition is records o = [add-record-row, enter-edit-at-cursor], and it already works, because EnterEditAtCursorCmd fronts its effect with RebuildLayout and the effect re-reads the layout itself (gotcha 23). Every Esc and every commit keypress would pay a layout rebuild and a CmdContext construction — which recomputes column widths — to buy that nothing.

-

And the ticket's motivating rewrite does not typecheck as a binding anyway. Its [(w, []), (force-quit, [])] cannot dispatch: w is register_pure, whose interactive constructor rejects an empty arg list, so run returns None and the whole binding is dead. The nullary spelling is save. every_bound_command_name_is_registered checks exactly this (gotcha 15) and would have caught it.

-

If command-level transactionality is ever wanted, it wants its own campaign: the abort must become a property of the sequence rather than of the batch before any step is applied separately.

-
-

#command/ — the command layer

-

#lib.rs / command/mod.rs / command/parse.rs

-

The crate is the Cmd/Effect pipeline, the keymaps and the App state machine, with no terminal dependency; rendering lives in the root improvise crate. All model mutations go through the command layer so they can be replayed, scripted and tested without the TUI: commands are trait objects (dyn Cmd) producing effects (dyn Effect), and the headless CLI (--cmd / --script) parses quasi-lisp text into effects and applies them directly.

-

improvise_protocol is re-exported as crate::protocol so a host — the ratatui binary, a wasm worker — can name a widget cache without taking its own improvise-protocol dependency, and so the cache types a host renders are the same ones ui::project builds (improvise-rkeq).

-

command::key re-exports improvise_protocol::key (improvise-cqi): the key types ride in ViewState::transient_prefix, so they must live where the wire types do, and the re-export keeps crate::command::key::… paths working.

-

parse.rs is the quasi-lisp prefix parser: word arg1 arg2 …, several commands per line separated by ., coordinate pairs written Category/Item, and quoted strings ("Profit = Revenue - Cost").

-

#cmd/core.rs

-

CmdContext is the read-only context commands decide against. Commands receive a &Model (pure data) and a &View (the active view); the full &Workbook is also 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.

-

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 map but its own ViewState field (improvise-gdl, gotcha 11). That knowledge used to sit inside the SetBuffer effect, where nothing paired it with the read side and every caller had to know that passing "search" meant something else than it said.

-

#cmd/cell.rs / cmd/navigation.rs — the explicit-state convention

-

All cell commands take an explicit CellKey; the interactive spec fills it from ctx.cell_key() and the parser fills it from Cat/Item coordinate args. Navigation commands take explicit cursor state the same way: the interactive spec fills position and bounds from context, and the parser accepts them as args.

-

page_cat_data gathers (cat_name, items, current_idx) for page-axis categories, deliberately using categories_on(Axis::Page) and not filtering_cats(): Filter coordinates constrain the data but are not navigable, so [ and ] must not page them (improvise-dz8, gotcha 27).

-

At bottom-right EnterAdvance has no place to go, so it emits a single AbortChain — the advance premise no longer holds, and the caller's trailing effects (a CommitAndAdvance's EnterEditAtCursor) are skipped.

-

#cmd/commit.rs — the two commit paths (gotcha 19)

-

commit_regular_cell_value commits a cell value: for synthetic records keys it stages in drill pending edits while in drill mode, or applies directly in plain records mode; for real keys it writes to the model.

-

rename_record_coordinate renames one coordinate of a records row. In wide format a row is a group of cells — one per measure — so the rename has to move every one of them, not just the cell under the cursor (improvise-c5v, gotcha 1). Every cell in the row moves, and a row seeded by add-record-row has no measure cells yet: its coordinates live on a lone measure-less cell, which must move too or the row loses its identity.

-

add_measure: typing a name into the trailing column creates that measure, which gives it a real column to type values into. Adding a measure is a schema change, so it applies immediately even inside a drill.

-

add_category (improvise-2zq): typing a name into the +cat column creates that category, which gives the row a coordinate column to type into. It creates the column and does not set the coordinate on the current row — the deliberate answer to the ticket's third question, for two reasons. It is symmetric with the measure +, which creates an empty column and writes no value. And setting it would move every measure cell in the row onto the new coordinate (gotcha 1): a data migration across N cells attached to the same Enter that made a schema change, in an app with no undo. The follow-up edit is one l away in the column that just appeared. Like add_measure it is a schema change, so it applies immediately rather than being staged.

-

inserts_a_column asks whether committing key inserts a column, invalidating every column index to its right. It is asked of the layout — record_col resolves the synthetic _Dim position into what the column actually is — rather than re-derived from the value or the cursor, so there is one answer to "what is this column" (gotcha 1).

-

exit_mode_for returns the normal-mode counterpart of an editing mode, used by CommitAndAdvance to compute the mode to land in if the advance aborts.

-

CommitAndAdvance commits a cell edit, advances the cursor and re-enters edit mode, subsuming the old CommitCellEdit (Down) and CommitAndAdvanceRight (Right). edit_mode is the editing mode to re-enter after advancing, supplied by the keymap binding — the editing-mode keymap passes editing and the records-editing keymap passes records-editing. The command itself never inspects ctx.mode.

-

Two orderings inside it are load-bearing.

-

The pre-emptive drop to normal goes first. It drops to the normal counterpart of edit_mode; if the advance succeeds the trailing EnterEditAtCursor lifts back into editing on the new cell, and if the advance aborts (already at bottom-right on Enter) that effect is skipped and the user lands in normal mode — the desired "Enter at bottom-right commits and exits" behaviour. It goes before the commit, not after (improvise-2zq): the commit can abort the batch itself — AddCategory does so at MAX_CATEGORIES — and an abort before this ran left the app in the editing mode, where draw_bottom_bar renders the minibuffer instead of the status line and the refusal the effect had just written was never drawn. Tidy-up goes where an abort cannot reach it; the same rule as ExecuteCommand's (gotcha 21).

-

A schema change that inserts a column suppresses the advance. Inserting moves every index to its right, and the advance is computed from ctx — the pre-commit layout, because apply_effects rebuilds only after the whole batch (gotcha 23). On the last row Enter wraps to col + 1, which after the insert is +cat again, so the editor reopened category creation and a user naming two categories in a row never reached the column they had made. Tab reached it the same way. Not advancing is also the useful answer rather than merely the safe one: add_category inserts the new column exactly where the cursor already is, so staying put lands on it. The measure + shares none of this, and not by luck — it is the last column, so there is no column to its right for an index to shift into, and both advances are already special-cased at the bottom-right edge.

-

CommitKind is what a minibuffer commit produces. The variants differ only in the model mutation they build and in where they leave the user — reading the buffer, trimming it, bailing out when it is empty, marking the model dirty and reporting are shared by CommitBuffer (gotcha 20). Item carries its target category, resolved from AppMode::ItemAdd when the command is constructed, so execute stays a pure function of the kind and its buffer. parse_commit_kind resolves the argument against the current mode: Item is the only kind that needs anything from the mode, so a script line (which has no live mode) can build the other two.

-

CommitKind::on_commit says where a successful commit lands the user. None keeps the minibuffer open for the next entry — categories and items are entered in batches, formulas are not.

-

CommitBuffer commits the contents of a minibuffer as a formula, a category or an item; buffer clearing is the keymap's (Enter → [commit, clear-buffer]). The add effect goes last on purpose: it is the only party that knows whether it worked, so the optimistic status must not clobber the error it reports (improvise-6t3).

-

CommitSave commits a save path from the save buffer. It is the sibling of CommitExport and deliberately not the same command: the export prompt writes CSV, this one writes the model through SaveAs, which also adopts the path so a later plain Save has one (improvise-lae). An empty path bails rather than reaching SaveAs, which would otherwise ask the storage backend to write to "" and report its error instead of the one the user can act on.

-

Its mode change comes first, and that ordering is the whole reason the user sees a failed save (found by Copilot on PR #27). SaveAs aborts the batch when the write fails (improvise-8tk's other arm), so anything after it is skipped. With the order reversed the batch stopped while still in SavePrompt — and draw_bottom_bar renders the minibuffer instead of the status line, so the "Save error:" set by the effect was never drawn. Leading with the mode change means the error lands in Normal, where the status bar is what the bottom row draws. It costs the typed path, which the welded clear-buffer would have taken on a success anyway and which EnterMode clears on the next Ctrl-W regardless (gotcha 11) — so nothing durable is lost, and the convention that the side effect runs last and its message wins is the one gotcha 20 already states.

-

#cmd/mode.rs

-

EnterMode clears the corresponding buffer when entering a text-entry mode, through write_buffer — the only code that knows a runtime-named buffer may not be an entry in the buffers map (improvise-gdl).

-

SaveAndQuit saves then quits; the Quit only lands if the Save succeeds, because a failed save aborts the effect chain, so wq/ZZ leaves the user in the app with the error visible rather than discarding the model (improvise-8tk).

-

It looks like an AAndB command and is not one. wq is the user's name for the composition, not an invented internal one: it is registered, so :wq reaches it, and Quit's unsaved-changes message advertises that spelling. A keymap Binding::Sequence cannot be reached from the : line, so replacing this with one would either delete the ex-command or leave two independently-written spellings of "save then quit". One definition behind two surfaces is what this already is. Pinned by text_buffer::tests::colon_wq_saves_then_quits.

-

EditOrDrill is the smart dispatch for i/a: if the cursor is on an aggregated pivot cell (categories on Axis::None and the cell is not a synthetic records-mode row) it drills instead of editing; otherwise it pre-fills the edit buffer with the displayed cell value and enters edit_mode. edit_mode is supplied by the keymap binding, so the command itself is mode-agnostic. Only regular (non-virtual, non-label) categories on None count as true aggregation: virtuals like _Index/_Dim are always None in pivot mode and imply nothing.

-

EditOrDrill::nothing_at_cursor is what to say when there is no cell under the cursor at all. improvise-ct5: it used to say "cannot drill — no cell at cursor" in both modes. Drilling is a pivot-mode concept, and in records mode — where every regular category sits on Axis::None, so this branch is the one an empty grid always takes — the user's model is "a table I type into", with o one key away to add a row. Naming drilling there described a mechanism the user is not using instead of the key that would help. The choice reads edit_mode, the argument the keymap supplies, rather than asking ctx what mode it is in, so the command stays as mode-agnostic as it claims and no runtime is_records() check comes back (improvise-4ju).

-

EnterEditAtCursorCmd is a thin command wrapper around the EnterEditAtCursor effect so it can participate in Binding::Sequence; target_mode is the keymap binding's argument.

-

#cmd/grid.rs

-

DrillIntoCell creates a _Drill view with _Index on Row and _Dim on Column (records/long-format view); fixed coordinates from the drilled cell become filters.

-

If drilling into a formula cell, the formula target is stripped from the key so matching_cells finds the underlying raw data records instead of returning nothing (gotcha 10).

-

Fixed coords go to Filter, an immutable constraint of this drill rather than a dimension to page through (improvise-dz8, gotcha 27). Previously-aggregated categories (none_cats) stay on Axis::None so they do not filter records; they appear as columns in records mode. Virtual categories are skipped, _Index/_Dim having been set already.

-

ToggleRecordsMode toggles between records and pivot mode using the view stack: entering creates a _Records view and switches to it, leaving navigates back. Leaving first cleans up any records with empty CellKeys — produced by AddRecordRow when no page filters are set — which is the inverse of the SortData that runs on entry.

-

#cmd/panel.rs

-

CyclePanelFocus tabs through open panels in order Formula → Category → View, wrapping. The starting point is the panel currently focused (ctx.mode); from a non-panel mode focus goes to the first open panel. With a single open panel it self-transitions back to it; with none open it is a no-op.

-

DeleteCategoryAtCursor deletes the category or item at the panel cursor: on a category header the whole category, on an item row just that item.

-

CreateAndSwitchView creates a new view, switches to it and returns to Normal. Not a shim, despite the name (improvise-aaw). The new view's name is derived from ctx — format!("View {}", views.len() + 1) — and a keymap Binding::Sequence can only carry literal args, so the proposed [create-view X, switch-view X, enter-mode normal] would hard-code one name and the second n in the view panel would collide with the first. The derivation is the decision that makes this a command.

-

The ticket's two fallbacks are both behaviour changes wanting a product decision, not refactors: prompting for a name is a new feature, and making create-view switch by default changes :create-view for scripts and duplicates what grid.rs does by hand for _Drill/_Records.

-

#cmd/registry.rs

-

parse_mode_name decodes a mode-name string (as supplied by enter-mode/edit-or-drill keymap bindings) into an AppMode. The table itself is AppMode::from_name, beside AppMode in the protocol crate: the spellings are keymap vocabulary and the keymap is shared, so the browser client decodes enter-mode from the same list (gotcha 16).

-

The commit registrations take a mode-name arg ("editing", "records-editing") as args[0]; the keymap supplies it and the command never inspects ctx.mode.

-

#cmd/text_buffer.rs

-

ExecuteCommand executes the : command buffer by delegating to the command registry — the : prompt is just another front end to the scripting language, using the same parser as improvise script.

-

Its batch leads with ReturnToNormalUnlessMoved, before anything that can fail runs. The mode is still decided at apply time rather than up front, because ViewBack, ViewForward and StartImportWizard move the mode only on some runs and no up-front answer is right (improvise-toc, gotcha 31). Running it at the head does not weaken that: whatever a later effect sets, it sets after this, so the mode a : line ends in is unchanged in every case — the only difference is what happens when the batch never reaches the end.

-

Which is the point. A failed Save/SaveAs sets abort_effects (gotcha 21), and an abort at the tail ate this restoration too, stranding the user in CommandMode with the stale command line drawn — where draw_bottom_bar renders the minibuffer instead of the status line, so the error the effect had just set was invisible. The abort must stop the chain (:w bad . q must not quit) and must not stop the bookkeeping, and ordering answers that without giving any effect a "runs anyway" flag: tidy-up goes where an abort cannot reach it.

-

#cmd/tile.rs

-

TileAxisOp cycles or sets the axis for the category at the tile cursor and stays in TileSelect mode so the user can adjust several tiles. axis: None cycles; axis: Some(a) sets.

-

#cmd/effect_cmds.rs

-

The parseable model-mutation commands are thin Cmd wrappers around effects, constructible from string args, sharing the same execution path as keymap-dispatched commands (gotcha 16).

-

#cmd/wizard.rs (improvise-2wm, improvise-907)

-

Import-wizard key handling. The wizard is a small step machine, and which key means what depends on the step it is in. That decision is pure, so it lives here: the command reads the wizard out of CmdContext and emits the discrete effects (WizardApply, CloseWizard, AcceptWizardModel) that carry it out.

-

done_keys is the confirmation step: the workbook is built and waiting, Enter installs it, and both Esc and Left return to NameModel — unlike every other step, Esc here does not throw the import away.

-

back_key: Left steps back to the previous step, on every step. It is the one key free everywhere — Backspace edits the name and the formula buffer, and h is a printable character the name step needs.

-
-

#command/cmd/fit.rs — fit-measure

-

Evolve a decision tree over what the active view shows and store it as an ordinary improvise formula. After this command there is nothing GP-specific left in the document — the rule is a ## Formulas line like any other, recomputed by the fixed-point evaluator, editable in the formula panel, drillable, and saved with no new grammar (improvise-21d's enabling fact, and improvise-7na's answer).

-

#The fit runs inside execute, and that is the architecture rather than a shortcut

-

A Cmd is pure over &CmdContext and returns effects; an Effect is the only mutation site. A fit is decision logic — a pure function of (Model, View, target, seed, FitConfig) that mutates nothing — so it belongs on the command side, and what the effects carry is its result. Putting the search inside an Effect::apply would have mixed an expensive stochastic computation into the one place the app mutates, which is the split's whole point.

-

What this does not buy is asynchrony. The command runs on the thread that draws the next frame, so a fit is bounded rather than cancellable: FitRequest::default is a deliberately smaller search than the CLI's (improvise-vx3's AC 4 asks for "cancellable or bounded"). Moving it off the main loop is a change to draw.rs's event loop, not to this file, and nothing here forecloses it — the fit is already a pure function that could be handed to a worker and have its FitOutcome posted back as the same effects.

-

#What a refusal is for

-

improvise_gp::fit_measure fails rather than storing something misleading — most sharply when the champion splits on an encoding artefact like Region=North, which is not an item of anyone's document, so every cell of the stored measure would be Error("ref:…") (improvise-8xhy, gotcha 37d). This command is where a user actually meets that restriction, so the refusal goes to the status line whole: it names the features that could not be resolved, because "the fit failed" is not something a user can act on and "it split on Region, which is a category rather than a measure" is.

-

Both the success and the failure lines are flattened to one line: several refusals interpolate a document name, and a name may legally contain a newline (Copilot on PR #25). A failure is when a user is most reading the status bar, so it is the last place to let one split. The report goes first and AddFormula last, so a formula the emitter spelled but the parser will not take leaves its message on screen rather than this command's optimistic one (gotcha 20).

-

#What registering it costs the browser, measured

-

improvise-command is in the worker bundle, and the path from App::new → default_keymaps → default_registry → this command's prototype is live, so gp-tree links into it whether or not a browser ever invokes fit-measure. kb.pl names keeping GP out of the wasm bundle as improvise-gp's reason to exist, so this is a cost to state rather than to discover: built with wasm-pack --release, improvise_worker_server_bg.wasm goes 1,341,130 → 1,420,470 bytes (+79 KB, +5.9%), or 498 → 531 KB gzipped (+6.5%). improvise-wasm-client — the main-thread bundle — does not depend on improvise-command at all and is unchanged.

-

Accepted rather than feature-gated, for two reasons. The worker already carries the whole App, persistence and the import wizard, so this is a 6% addition to something an order of magnitude larger; and a browser fit is a wanted end state, not dead weight — improvise_gp::fit exists where it does precisely so a second host can run the same sequence. If the bundle ever needs shrinking, a default-on cargo feature that web/build.sh turns off is the lever, and these are the numbers to beat.

-

#parse_seed and the seed argument

-

A seed as the status line spells it (0x…, either case) or in decimal. The hex arm is not a convenience: FitOutcome::status_line prints {:#x} and tells the user that is what reproduces the fit, so accepting only decimal made the advertised round trip fail on the advertised value.

-

The seed is an argument rather than a clock read for the reason the CLI fixes its own default: two fits of the same document should agree, and a user who wants a different search asks for one (gotcha 37b).

-

#The active view is split (improvise-jytm)

-

Workbook::active_view is the document's default — what a freshly connected client opens, and what .improv writes as Initial View. ViewState::active_view is authoritative for the client that holds it. Two clients may sit on different views of one document: the model is shared and the UI is not (improvise-g0f, user ruling 2026-09-19).

-

The old guard, the_two_active_view_names_never_diverge, is replaced rather than deleted, by the_documents_default_view_outlives_this_clients_switches and by a_fresh_session_opens_the_documents_default_view — a deliberate split with no guard is how this silently regresses.

-

#What the split moved

-

App::switch_to_view writes only view_state.active_view, and only for a view the document has. App::resolve_view is the one answer to "what does this name name in this document", falling back to the default so a slice naming a deleted view cannot panic in rebuild_layout; view(), view_mut(), view_name() and project::grid_in all go through it rather than each testing views.contains_key. Two copies of that fallback would be a derivation done twice — they would agree until one of them learned about _Drill. Every effect that used to write workbook.active_view_mut() now writes app.view_mut() — the View is still document state shared by everyone on it; what changed is which one this client means.

-

effect::SwitchView, effect::ViewBack and effect::ViewForward are therefore EffectKind::ViewOnly. Gotcha 3 says a mis-declared kind is silent, so this was re-derived rather than assumed: after the split each of the three writes view_state and nothing else, and a ViewOnly effect is exactly one that reaches no other client. A sound derivation is not a guard, though, and review found that only SwitchView had one — putting either sibling back to Model(LayoutGeometry) was invisible across the whole suite. walking_the_view_history_moves_no_document_state closes that, and it asserts the consequences rather than the declaration because those are what a reader can price: a mis-declared view-back raises model_state.dirty (so the browser worker rewrites the whole OPFS document, gotchas 41 and 45) and bumps layout_version (so every other client's next Command::Model is refused LayoutMoved, ADR 001) for a move only one client made. That re-classification is also what takes ViewBack/ViewForward off the wire, since ModelCommand is derived from the Model(..) effects — and it is the right answer for a second reason, the one that removed ApplyAndClearDrill: their operand is ViewState::view_back_stack, session-local residue belonging to whoever navigated (gotchas 30, 38).

-

#What projects per client

-

Every projection is view-dependent except the formula list and the view list, so grid_in, category_tree_in and tile_bar_in take the view and ClientSession::project_diff passes its own. All three need guarding, not just the grid: review found that only grid_in had a check, and pointing ClientSession::view_in at the host's view left the whole suite green. The one that matters most is the tile bar — a category's axis is what that widget is, so a client on another view would be shown the wrong axis in the control it reassigns axes with, beside a grid that was right. grid_in reuses App::layout when the session names the view the host is on — which is the common case and the only one that can carry a drill snapshot — and derives a GridLayout otherwise.

-

A session on a view that is deleted is the other half, and it is answered in adopt_deleted_views rather than here: project can only describe a view, it cannot move a client off one.

-

Testing any of this needs a bystander that can be moved. Both the refused-switch and the deleted-view tests first sat their bystander on the document default, where a relay addressed to everybody folds to a no-op — so the assertion read as coverage and caught nothing, which review demonstrated by injecting ToAll twice. Both now put the bystander on a view it can be moved off, and both read the audience off the Broadcast itself rather than inferring it from folded state. The audience is the message; that is what makes it the thing to assert.

-

A switch is ViewOnly, so Dirty cannot carry it. ClientSession therefore observes its own move: projected_view records the view the mirror's grid was built from, and a mismatch projects Dirty::EVERYTHING for that session alone. This is the cursor_epoch shape (gotcha 24) — watch the state move rather than keep a second table of which commands move it.

-

#Two gaps this opens, both filed rather than smuggled

-

A view-scoped ModelCommand names no view (improvise-lltt), and it is deferred on a condition, not a judgement: it is unreachable only because no shipped client emits Command::SwitchView, while the browser already emits TransposeAxes. SetAxis, CycleAxis, TogglePruneEmpty, SetNumberFormat and their siblings act on "the active view", which is now ambiguous: apply_model runs them against the App's own slice, so a connected client on another view moves the host's view rather than its own. That was consistent before the split, because every client was on the same view. The honest fix is for those variants to name their view, which is a protocol change.

-

Initial View no longer follows the TUI user (improvise-azhb). A switch moves no document state, so opening a document, switching view and saving writes the view it was loaded with. Whether a host's own switch should also set the document's default is a product question, not something to decide inside this ticket.

-

#"What text is in this cell" has one answer, plus an overlay (improvise-tom1)

-

The user has ruled: a remote client must not see another client's staged drill edits. They are uncommitted writes belonging to whoever staged them, which is why gotcha 30 keeps drill_state off ViewState and why gotcha 38 took ApplyAndClearDrill off the wire. They become everyone's business at ApplyAndClearDrill, which is CellData and already reaches every client. So the invisibility is correct and was never the defect.

-

The defect was that three places answered the question. Two of them are now one, and the third turns out to have stopped being an answer.

-

GridLayout::records_value is the resolution. It returns the CellValue, and records_display is one to_string over it, so the text can never disagree with the value it is a spelling of. project::cell_display used to call records_display and then guess numeric-ness with text.parse::<f64>() — the one place the producer guessed rather than knew, and it was wrong in a way nothing caught: a coordinate column holds an item name, so a Year category with items 2024/2025 projected a coordinate the browser right-aligned as a number. It now reads the value and matches.

-

A coordinate is CellValue::Text by construction, not by a check, which is why the fix is in records_value rather than in a guard at the producer: there is no spelling of an item name that could route it to the numeric branch.

-

src/ui/grid.rs was not the third answer. Since gotcha 44 the TUI grid takes its text off GridCache and then overlays drill_state.pending_edits — that is the one derivation plus a session-local overlay, which is exactly the shape the ruling wants, and App::display_value_of does the same for the editor. The ticket's "three answers, two of which agree" was written before anr9 landed and is worth correcting rather than repeating.

-

What must stay true, and is now asserted: the session that staged an edit sees it through display_value_of, and grid_of projects the committed value to everyone, including that session's own grid. The producer never reads drill_state, and teaching it to would put one client's uncommitted typing on another's screen.

-
-
- - diff --git a/site/docs/code/core.html b/site/docs/code/core.html deleted file mode 100644 index 14637987..00000000 --- a/site/docs/code/core.html +++ /dev/null @@ -1,159 +0,0 @@ - - - - - -improvise-core — improvise docs - - - -
- -
-

Source: context/code/core.md

On this page

improvise-core — the arguments

-

Written for a reader with the code open. This is where the reasoning that used to live in crates/improvise-core/**'s comments now lives; the comments themselves are capped at two lines (design-principles §6). Bug narratives keyed by test name are in context/test-evidence/core.md.

-

The crate is pure data: Model, View, GridLayout, Workbook, number formatting. It depends on improvise-formula for AST types and knows nothing about UI, IO or commands.

-
-

#1. CellKey is canonical by construction

-

CellKey(Vec<(category, item)>) is always sorted by category name, and the constructors are the only way to get one: CellKey::new(coords) sorts, with(cat, item) replaces-or-inserts-and-re-sorts, without(cat) filters and so preserves the order. Equality, hashing and storage all rely on the canonical form, so two keys naming the same intersection in different orders are the same key. Never build the inner Vec directly (gotcha 5).

-

That without preserves the sort is what lets record_of be a one-liner rather than a normalisation step — see §5.

-

#2. Aggregating a cell's raw values

-

Three functions, deliberately with three different emptiness rules, and the differences are the whole content of the design (improvise-kdp):

-
    -
  • apply_agg(values: &[f64], func) — reduces numbers. None on empty: an empty cell reads as absent, not as zero, or every blank intersection in a pivot would render 0. Both aggregation paths (evaluate_aggregated and aggregate_raw) go through it so their semantics cannot drift.
  • -
  • aggregate_cell_values(values: &[&CellValue], func) — reduces a mixed set. See below.
  • -
  • eval_agg(...) — evaluates a formula-level SUM(x) / AVG(x WHERE …). Its emptiness rule is not apply_agg's: an aggregate over no rows is 0 for SUM/COUNT, which have an identity, and an error for AVG/MIN/MAX, which do not.
  • -
-

#evaluate_aggregated takes Aggregation, not a list it cannot read

-

The parameter was none_cats: &[String], and the body consulted it exactly once, for emptiness — a boolean wearing a list's type, since the aggregation itself runs over key.0 through matching_values. It never filtered by that list, in any revision: git log -S back to the commit that introduced Axis::None shows the same is_empty() and nothing else. So the name promised a filter the body has never performed, and the next caller to build a careful list would have threaded it into a parameter that cannot use it.

-

Aggregation::{Exact, OverHidden} admits only the two states the body distinguishes, and Aggregation::of(none_cats) performs the reduction once, at the boundary that holds the list (improvise-5jm5, doctrine(an_invariant_is_made_unrepresentable_not_validated) — the same answer prune_axis_items and gp-tree's Row<T> got). It is an enum rather than a bool because the two states have names worth reading at the call site, and the regression guard is the signature: the thing the ticket describes — two different lists, same emptiness, same answer — is no longer constructible.

-

Note what does not change: recompute_formulas and aggregate_raw keep &[String], because they walk the elements (stripping each hidden coordinate off a stem). Two parameters that looked alike were doing two different jobs.

-

#Non-numeric values reduce by unanimity, and the branch order is the rule

-

aggregate_cell_values checks the all-numeric branch first. If every value is a number it reduces through apply_agg; otherwise, if every value equals the first, the result is that value; otherwise it is CellValue::Error("mixed").

-

That ordering is the entire difference between this rule and "a single value passes through". Count is the only function that can tell them apart — every other function is the identity on a one-element set — so Count of a lone number must still be 1 and not the number itself. a_lone_number_still_counts_ as_one_rather_than_passing_through is the pin.

-

What the rule forbids is dropping. The old code did filter_map(as_f64), and since _Index/_Dim sit on Axis::None in every default view, none_cats is non-empty almost always and the aggregating path was almost always taken — so every CellValue::Text in every document rendered blank, including the pipe-quoted text .improv carries as a first-class value (Region=West, Measure=Revenue = |pending|). A user could type a text value, save it, load it, and never see it again (improvise-l4qx).

-

Both evaluate_aggregated and aggregate_raw share the helper, so a formula ref over mixed values now errors "mixed" rather than silently summing the numeric ones and reading as authoritative while under-reporting.

-

#3. Formula evaluation

-

Fixed point. recompute_formulas(none_cats) clears formula_cache, collects the distinct coordinate stems (each data key with the formula's target category and every none_cat stripped), and iterates stem × formula target until nothing changes, bounded by MAX_EVAL_DEPTH = 16. Chained formulas (Margin = Profit / Revenue over Profit = Revenue - Cost) converge naturally; circular references converge to CellValue::Error("circular") rather than overflowing the stack. The stems are an IndexSet: dedupe is O(1) per cell and insertion order is preserved (improvise-6os replaced an O(n²) Vec::contains).

-

There are two evaluators, and they must be changed together:

-
    -
  • eval_formula_depth / eval_expr — the direct, uncached path, recursion bounded by depth.
  • -
  • eval_formula_with_cache / eval_expr_cached — the fixed-point path, which resolves a Expr::Ref through formula_cache first and falls back to aggregate_raw.
  • -
-

A rule stated in one and not the other is a live bug; improvise-0bf was exactly that (see §4).

-

find_item_category searches real categories first and then formula targets, because add_formula never inserts its target into _Measure (improvise-nrs). Without the fallback, a reference to a computed measure fails to resolve.

-

Display rounding never reaches eval. format_f64 is called only in rendering paths. Feeding a rounded value back into a formula is the bug formula_chain_preserves_full_precision exists to catch.

-

#4. Float equality has one epsilon, and the div/0 guard shares it

-

FLOAT_EQ_EPSILON = 1e-10 is used by =/!= and by the division-by-zero guard, in both evaluators. The two must agree or IF(x = 0, a, y / x) is incoherent: the condition calls x zero and the else branch divides by it anyway. With x = 1e-11 the old exact rv == 0.0 guard returned 1e13 (improvise-0bf). 1e-10 is far below typical data magnitudes but absorbs accumulated f64 rounding noise from chained formulas.

-

Note the repo map's gotcha 17 (which called this "currently mixed") describes the pre-fix state; the guard and the comparison share the constant now.

-

#5. Surrogate identities — model/record.rs (ADR 002)

-

A client sees a grid of label strings indexed by viewport position. It holds no Model, no category names and no page/filter constraints, so it cannot build a CellKey — which is why improvise-q08 found a thin client cannot compose SetCell. The answer is an opaque surrogate the server projects per visible cell and the client echoes back.

-

Two surrogates, because two kinds of thing are nameable and only one is settable. RecordHash names a record — the coordinate tuple minus _Measure (record_of). That unit is not chosen here: it is already ADR 001's unit of atomicity and already what the write counters are keyed by, because renaming a category coordinate in wide records mode moves every measure cell in the row (§6). One identity therefore serves addressing and staleness. AggregateHash names an aggregate's coordinate constraint — row and column coordinates plus the page and filter selections that decide which stored cells it sums (View::filtering_cats(), §7). An aggregate is not the absence of an identity; it is a different kind of identity, which is what makes Drill addressable at all. It is deliberately not normalised: a measure on Row or Column is part of what the aggregate sums, so dropping _Measure would give two visibly different totals one name.

-

The hash is specified here, not inherited. Three ways it breaks silently, each producing a wrong write rather than an error:

-
    -
  1. Not DefaultHasher, not RandomState. std's default HashMap hasher is randomly seeded per process, so a surrogate would not survive a restart or agree between two processes. This is SipHash-2-4 under a constant key.
  2. -
  3. Not #[derive(Hash)]. Rust gives no stability guarantee for a derived Hash across compiler versions or platforms. What is hashed is the explicit byte serialization in hash_coords, so the surrogate is defined by that format rather than by whatever the compiler did this week.
  4. -
  5. 128 bits, argued rather than defaulted. A collision is a silent write to the wrong record — precisely what got _Index rejected as a surrogate — and no later check catches it: a colliding surrogate resolves successfully to a record the client never saw. 64 bits reaches birthday risk around 2^32 records, which a pivot tool will not see by live record count; but the risk is over every record a long-lived document ever names, and the consequence is unbounded and undetectable. The other choice costs 8 more bytes per visible cell — 8 KB on a full 50×20 projection and far less on the diffs that carry steady-state traffic. Bounded cost against silent corruption is not close.
  6. -
-

Serialization details that are load-bearing. The byte format is tag ‖ count ‖ (len(category) ‖ category ‖ len(item) ‖ item)*, every length a little-endian u64. The lengths are what make it injective: without them ("ab","c") and ("a","bc") serialize alike. Only Hasher::write is used, never write_u64/write_u8, which are free to feed the hasher in native byte order. The SipHash key constants are written as big-endian readings of b"improvis"/b"e-record" so the constant is the same on every platform. A domain tag byte (0x01 record, 0x02 aggregate) is hashed first, because a record and an aggregate may be built from the same coordinates — an aggregate over a single record is the degenerate case — and they must not hash alike.

-

Hex on the wire, not a JSON number. serde_json writes a u128 as a bare number and a JavaScript host parses every JSON number as f64, so anything above 2^53 comes back changed — which for a surrogate means resolving to a different record or to none. The wire form is a fixed 32-character lowercase hex string.

-

#6. Records mode is wide format (view/layout.rs)

-

A row is one coordinate tuple — the cells sharing everything but _Measure — and each raw measure gets its own editable column. The columns are [coordinates…, +cat, measures…, +]: one per regular category, then the column that creates a new category (improvise-2zq), then one per raw measure, then the column that creates a new measure (improvise-c5v). Each + sits adjacent to what it adds to; two trailing columns would have made the user read a label to tell them apart, and position says it instead. Formula targets are not columns — they are computed, so there is nothing to type into them.

-

Consequences: two measures on one coordinate are one row; editing a category column moves every measure cell in the row, not just the one under the cursor; and _Dim in the synthetic key carries the column index, not a label, because a category and a measure item may share a name and a label could not say which column an edit landed on. Resolve the index through GridLayout::record_col into a RecordCol. Drill pending edits are keyed by (row, RecordCol).

-

RecordRow::group collapses loose cells into wide rows in first-appearance order, so the result follows the store's order rather than a hash walk (§8). A cell with no _Measure coordinate still anchors a row — that is how add-record-row seeds one — but contributes no column value.

-

Records mode is a property of the view's axes, and View owns the one definition. View::is_records_layout() (_Index on Row and _Dim on Column) is what GridLayout::new asks and what on_category_added asks; two derivations would drift (improvise-edp, the disease this codebase keeps curing).

-

#prune_axis_items takes a predicate, not a flag vector

-

prune_empty hands it impl Fn(usize) -> bool over the data index — the position a row/col argument means, which group headers do not consume. The predicate's domain is derived from items itself, so there is no parallel length a caller can get wrong and debug and release builds cannot disagree about an out-of-range flag. The earlier &[bool] + debug_assert pair could have, had a third caller ever sized keep from something other than row_count()/col_count() (improvise-n3wt, doctrine(an_invariant_is_made_unrepresentable_not_validated)).

-

A group header survives only when a data item that follows it survives. In prune_empty the row pass does not touch col_items or has_value, so the column pass still reads the original grid. Records mode is exempt entirely — the user drilled in to see all the raw data.

-

#7. Axes: Page and Filter are two different questions

-

Axis is Row | Column | Page | Filter | None. Filter names a coordinate the drill fixed (improvise-dz8): like Page it constrains which records are visible, but unlike Page the user cannot navigate it. Axis::next() maps Filter to itself and no navigable axis rotates into it, so tile select cannot hand one out, and is_user_assignable() is false for it.

-

Picking the wrong question is the trap:

-
    -
  • "what data is visible" → View::filtering_cats() (Page ∪ Filter). Used by GridLayout, by AggregateHash, and by add-record-row when it builds a key from the current constraints.
  • -
  • "what can the user act on" → categories_on(Axis::Page) for [/] page cycling, or View::tileable_cats() for all three tile-select sites.
  • -
-

Axis also owns four presentation vocabularies — plain_label(), glyph() (which Display appends, giving "Row ↕"), short_label() (three columns for the tile bar; None is "·", not a truncation of "None") and initial() (one letter for the view panel; None is empty) — plus color() -> AxisColor. AxisColor is a semantic palette because improvise-core must not name a ratatui type; draw.rs::axis_fg does the one mapping, exactly as minibuf_fg does for MinibufColor (improvise-rml). The tile bar and the category panel each carried a copy of the colour table before, with nothing keeping them in agreement.

-

#on_category_added: a virtual on an axis is a configuration, not a vacancy

-

Virtual (underscore) categories seat on Axis::None. A regular category takes the first slot no regular category holds — Row, then Column, then Page.

-

This used to bump a virtual off Row or Column to make room, assuming a virtual sitting there was a placeholder. It never is, and the assumption cost two live bugs (improvise-2zq):

-
    -
  • _Index on Row together with _Dim on Column is records mode, and the only production writers of those axes are the records and drill setups. Bumping _Index ejected the user from the grid they were standing in: naming a category with N in records mode silently returned them to a pivot.
  • -
  • _Measure on Column is an ordinary user configuration — it is what web/demo.improv ships. Bumping it made every measure vanish from the grid the moment a second regular category was added.
  • -
-

So a virtual is never moved. A records view seats the new category on Axis::None, which is where records mode keeps every regular category and where records_columns — which filters on kind.is_regular() and never looks at an axis — picks it up as a column immediately. Elsewhere the new category simply joins whatever virtual is already there, nesting a header rather than destroying one.

-

The negative arm is load-bearing and must stay tested: without a_pivot_view_still_seats_categories_row_then_column_then_page, "always seat on None" would pass every assertion about records mode. Probing confirmed the asymmetry — seating everything on None fails 12 tests, while removing the virtual bump failed none at all, which is how the bump came to be wrong in two directions with nothing noticing.

-

#Known defect: two answers to "is this name a virtual"

-

on_category_added tests cat_name.starts_with('_') (twice) while is_records_layout() four lines away asks CategoryKind::of_name. That is improvise-bidq and it is open; do not assume either spelling is the intended one when editing near it.

-

#8. Ordering is correctness, not tidiness (model/cell.rs)

-

DataStore holds cells in an IndexMap keyed by InternedKey, plus a secondary index HashMap<(Symbol, Symbol), HashSet<InternedKey>>.

-

An index whose values are HashSets is for O(1) lookup, never for iteration. matching_cells/matching_values used to walk the index's HashSet directly, so their order was hash order — different in every process, and different between two stores in one process, because RandomState keys each HashMap afresh. That defeated the whole reason cells is an IndexMap, and it was not tidiness (improvise-shyt). It caused, in ascending order of seriousness:

-
    -
  • records-mode rows reordering between sessions;
  • -
  • SUM/AVG drifting in the last f64 bits, because f64 addition is not associative (Min/Max/Count and the text-unanimity rule of §2 are order-free, so they were never affected);
  • -
  • worst, the drill snapshot freezing its record list in hash order while drill_state.pending_edits are keyed by row index (§6) — so the same drill opened in a second process addressed different records. Silent misdirected writes, and nothing in the tracker knew.
  • -
-

matching_positions is the fix: the index still narrows the candidates, and the primary map orders them — each surviving candidate resolves to its position with one hash lookup and the positions are sorted. Shape stays O(smallest set × |partial|) plus O(m log m) over the matches, not a scan. Measured at 18,000 cells: +20% worst case on matching_values, and matching_cells 7% faster, sorted positions resolving CellKeys with better locality. The alternatives both move the cost — an ordered index value type pays on every set; scanning the primary is O(store) per query.

-

An empty partial matches every cell, vacuously, and matching_positions is where that is answered (improvise-hkt6). It used to index sets[0] and panic; both public callers happened to guard, so it was unreachable — but the guard lived one level away from the indexing, which is how this class of bug returns. The guards could not simply be pushed down as vec![]: "no coordinates constrain anything" means all positions, and answering "none" here would have made removing a caller's fast path change behaviour silently. The callers keep theirs as a fast path, not as a guard.

-

live_records has a decided order, not an inherited one (improvise-2yc2). It walks cells and projects insertion order onto records: a record sits where its first cell sits. It deliberately pays O(cells) rather than O(records), because cells' order is already the answer matching_positions gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on RecordEntry, since a stored position is a copy and a record deleted and re-created would hold its old one while its cells sit at the end of the store. Note what is not on offer: an unordered return. Every iterator has an order, and a type that merely disclaims one still hands the caller hash order — the guard living one level away from the hazard, which is the shape this whole class of bug returns through. The function had no callers when this was decided, so the order was still free to decide rather than to discover; with no caller to benchmark against, correctness is the defensible default, and a hot-path caller can argue for a memoised position with numbers.

-

#The record index: RecordEntry

-

DataStore::records: HashMap<RecordHash, RecordEntry> answers both of a client surrogate's questions — "which record do you mean?" (ADR 002) and "is your basis stale?" (ADR 001) — from one entry in one lookup, because both are keyed by the record and both are invalidated at the same moment.

-

It lives in the store because this is where the same job is already done: the store already interns every name and already maintains a secondary index updated at exactly these two points (set, remove), and it is that existing (Symbol, Symbol) index which makes invalidation tractable — when a category or item disappears, the cells naming it are found without a scan, so the records built from them are too.

-

It is deliberately not regenerated from the layout on demand. That would be O(visible cells) per resolve and would only ever cover the visible window, so a record scrolled out of view would look identical to a record that no longer exists — conflating "reject, nothing to write to" with "apply".

-

It is not serialized: Deserialize rebuilds it through set, and every version starts at zero, which is correct because a freshly loaded document is a new document_generation and no client holds a basis against it.

-

cells == 0 is a tombstone, not a reason to forget the entry. A record that is deleted and then re-created must not hand a client holding the old basis its own version number back; dropping the entry would reset the counter to zero and make a stale basis look current, which is the silent accept the basis check exists to prevent. The counter tracks liveness, not writes — set bumps cells only for a genuinely new cell, and version is moved by the observed diff in App::apply_effects, never from set.

-

resolve_record returning None means the record is gone — never stored, or every cell removed, which is what happens when a category or item it named is deleted. That is one arm of staleness and needs no version comparison: there is nothing to write to. record_version reporting 0 for an unknown surrogate is deliberate: a basis composed against an absent record and one composed against an untouched record are the same claim.

-

#9. Categories

-

CategoryKind is Regular | VirtualIndex | VirtualDim | VirtualMeasure | Label. Business rules match on the enum rather than sniffing name prefixes.

-

CategoryKind::category_name() is the one place the name↔kind mapping is written down, so adding a variant forces a decision there (improvise-2lh). of_name(name) is safe to use in place of looking the Category up, because Model::add_category hands back the existing virtual category rather than shadowing a reserved name — stored_virtual_categories_match_their_names is what makes that sound. Label categories are user-named and so read as Regular from of_name; consult the stored Category::kind when the distinction matters.

-

VirtualMeasure items come from two sources: numeric data fields listed in the file, and formula targets added implicitly. They are virtual because formula-derived items are implied by the formula definitions — listing them explicitly would be redundant in the file format and confusing in the UI. add_formula does not add items to _Measure; use measure_item_names() / effective_item_names("_Measure").

-

Label is a high-cardinality per-row field (description, id, note): stored alongside the data so it shows up in record/drill views, but defaulting to Axis::None and excluded from the MAX_CATEGORIES = 12 pivot limit and from auto Row/Column assignment.

-

Category::add_item deduplicates by name and assigns IDs from a private counter, so external code cannot fabricate an ItemId.

-

#10. Workbook exists to break the Model ↔ View cycle

-

Model knows nothing about views; View depends on Model, one direction. Cross-slice operations — adding or removing a category must update both the model's categories and every view's axis map — live on Workbook, so Model stays pure data and improvise-core can be extracted without pulling view code along. That is also why AddCategory/RemoveCategory are LayoutGeometry effects rather than CellData ones (gotcha 3).

-

Workbook::new seeds a Default view, registers the three virtual categories through on_category_added (so all three land on Axis::None), then bumps _Measure to Axis::Page so aggregated pivot views show a single measure at a time (improvise-kos, commit 709f2df). Leaving _Index/_Dim on None keeps pivot mode the default — records mode activates only when the user moves both onto axes.

-

Workbook::add_item errors on an unknown category so callers do not reach through model.category_mut(..) and invent their own handling (improvise-tvt). There is no Model::add_item.

-

#11. Virtual views

-

_Records and _Drill (view::VIRTUAL_VIEW_NAMES) are scratch views the app builds on demand. Both put the virtual _Index/_Dim categories on axes, and those categories are never persisted — so these views are rebuilt from scratch each time rather than saved (improvise-60z).

-

#12. axis_of panics, try_axis_of does not

-

Registration is guaranteed for every model category: Workbook::new, add_category, add_label_category and create_view all call View::on_category_added for every view. So any name obtained from Model::categories or from the view's own category_axes is safe for axis_of. Use try_axis_of when the name comes from anywhere else — cycle_axis does, and no-ops on an unknown category to match set_axis (improvise-t8s).

-

#record_cell_key is a match, and the match is the guard (improvise-ir7l)

-

GridLayout::record_cell_key(row, col) is the one mapping from a records position to a model cell, and it answers Some for RecordCol::Measure alone. The other three arms are spelled out rather than swept into a _, and that exhaustive match is the whole guard: a new RecordCol variant fails the build until someone decides whether it names a stored cell.

-

There is no test for the Category arm, and the reason is not that the wrong answer would be harmless. It would be a real fault, and a subtle one worth naming so nobody re-derives it wrongly. RecordCol::Category(name) carries a category name, while RecordRow::cell_key(arg) puts its argument under _Measure. So answering Some there yields coords + _Measure=<category name>, and Model::formula_for would match it whenever a _Measure item happens to share a name with a regular category — projecting CellId::Derived for a coordinate column, which is never a formula target and which the pivot path does not have at all. The two front ends would then disagree, which is the design-principles §5 defect this ticket exists to close. RecordCol exists for exactly that collision: its doc says the enum resolves a column from its index "so a category and a measure sharing a name stay distinguishable".

-

What earns the decision to leave it untested is narrower: the reachable version of that fault is a new variant answered carelessly, and the exhaustive match makes that unrepresentable rather than merely unlikely (doctrine(an_invariant_is_made_unrepresentable_not_validated)). A test would pin behaviour the type system already pins, and would not survive the arm being renamed. Found in review, and the first recorded reason for declining it was wrong — it claimed the injected behaviour was defensible by analogy with a formula targeting a regular category, which is a different key entirely.

-

#13. Route then aggregate: SUM(<expr>) folds over the member records

-

improvise-4zl7, and the one place the evaluator reaches past the cell it was asked about.

-

The problem. A formula is evaluated at a CellKey. In an aggregating cell evaluate_aggregated aggregates each Expr::Ref first and evaluates the expression once on the totals. That is exactly right for a linear formula — SUM(Revenue) - SUM(Cost) and SUM(Revenue - Cost) are the same number — and exactly wrong for anything that routes. IF(Cost < 50, 1, 10) over records costing 10 and 100 answers 1 + 10 = 11 per record and 10 on the total, because aggregate-then-route is not route-then-aggregate. Every fitted tree (improvise-8xhy, improvise-vx3) is such a formula, so it meant one thing on a record row and something else one row up. The user's ruling was to aggregate the rule over the members.

-

The mechanism, and why it needed no new vocabulary. SUM(e) already means "fold e over the members". It simply did not: eval_agg specialised Expr::Ref and, for every other inner expression, summed the raw cells matching the partial key and ignored the expression entirely. It now folds — one entry per member record, the expression evaluated at that record's own coordinates:

-
partial   = context.without(target_category)    (plus any inline WHERE)
-records   = distinct(matching_cells(partial).map(key.without("_Measure")))
-values    = records.map(|r| eval(inner, context.with(every coord of r)))
-

Three consequences worth stating.

-

The rule is written once, and so is the evaluator it folds with. eval_agg is shared by eval_expr and eval_expr_cached, and both hand it the same eval_at: the record-level eval_expr. That is not a convenience, it is the correctness. A member key is a record, and formula_cache is keyed by stems with none_cats stripped — so the cached evaluator's Expr::Ref can never hit the cache at a member key, falls through to aggregate_raw, finds no raw cell behind a formula target, and answers Err("ref:…"). A first version did exactly that, and the two evaluators then disagreed on any routing formula reading a derived measure: Number(4.0) uncached, Error("ref:Profit") cached, which is the very divergence this section exists to remove (§3: "a rule stated in one and not the other is a live bug").

-

And making the lookup "work" would have been worse than the error. The only way to hit the cache from a member key is to strip none_cats off it — which returns the group's value for that formula and hands it to every member. That is aggregate-then-route again, laundered through a cache lookup, and it would have been silent. The cache cannot answer a per-record question, so the fold does not ask it. Only Expr::Is needed an arm in each evaluator, and since the fold routes through eval_expr, the cached arm is now reached only by a bare Is outside a SUM — which is what the_cached_evaluator_has_its_own_working_coordinate_predicate pins.

-

The Expr::Ref fast path stays, and it is the same fold specialised: a record's Ref is that record's own cell, so summing them is summing the matching values. It differs in exactly one place — a record with no such cell is skipped there and an error in the fold. That is deliberate: a bare SUM(Revenue) asks for the values that exist, while SUM(<expr>) asks the expression of each member, and an expression that cannot be evaluated is an error rather than a zero. Silently dropping members is how a routed sum would quietly answer for a subset.

-

The asymmetry's blast radius is wider than it sounds, and worth stating: gotcha 7 makes a pipe-quoted text cell a first-class .improv value, so one text cell anywhere in a member set turns the whole aggregated fitted cell into Error("ref:Cost"), where the bare-ref path would have dropped it and returned a number. That is the loud failure this repo prefers to a quietly wrong sum, but it is a real behaviour difference and not a detail.

-

Records come out in DataStore insertion order, deduplicated by an IndexSet, for §8's reason — hash order here would make a fitted cell drift in its last bits between processes.

-

Interaction with the fixed point (§3) and formula_cache. None of this is new state. The fold reads raw data through the same evaluate_depth / aggregate_raw the non-folding path uses, so a formula cell still depends only on raw cells and on other formula cells' cached values; recompute_formulas iterates the same stems to the same fixed point, and formula_cache still holds exactly the aggregated formula cells it held before. The fold does not cache and does not memoise: it is O(members) per evaluation per pass.

-

Measured, because the cost was a blocker and not a footnote (improvise-9iyv; crates/improvise-core/examples/fold_bench.rs, five runs per point, best–worst). At 18,050 cells with one routing formula and Tag on Axis::None, recompute_formulas takes 18.1–19.2 ms release and 132.9–135.1 ms debug, against 7.1–9.0 ms / 71.7–74.6 ms for a linear Rev - Cost. That is 2.3× a linear formula and linear in cells: 400 → 0.4 ms, 2,000 → 2.0 ms, 18,050 → 18.1 ms, so 45× the cells costs 45× the time.

-

It was not always linear. Routing the fold through eval_expr_cached — the first version, above — made every member's Ref miss the cache and fall into aggregate_raw, one poorly-narrowed scan per member: 51.1–53.3 ms at the same 18,050 cells and superlinear with it (45× the cells cost 85× the time). So the divergence and the stall were one defect, and fixing the first fixed the second.

-

And "18,050 cells" was not a sufficient description of that workload. At the same cell count the old code cost 51 ms at a 25×361 shape and 244 ms at 95×95 — a 4.8× spread from group count alone, which is what a per-member scan predicts and a constant factor does not. After the fix the two shapes are 16.9–17.2 ms and 16.4–17.5 ms: indistinguishable. The shape-dependence being gone is the confirmation of the cause, more than the absolute number is. Re-measuring this means stating the shape, not just the cells.

-

The fixed point runs exactly 2 passes (measured, not assumed), so hoisting the member set out of the loop has a 2× ceiling on the fold's share and was declined: a memo is state, and a memo keyed by anything less than the whole partial is a second answer to "what does this cell depend on".

-

One asymmetry not to tidy: the cached path hands eval_expr a fresh MAX_EVAL_DEPTH at each Agg, while the uncached Agg threads its own depth through. The second is what bounds a chain of folded formulas; making them agree by resetting both would unbound it.

-

Expr::Is(Filter) — a coordinate predicate as a number. Cat IS Item is 1.0 when the cell's own Cat coordinate is Item and 0.0 otherwise. It keeps the evaluator f64-only — no string comparison enters eval_expr — and it reuses the primitive Formula::filter already had, context.get(&cat), including its unwrap_or(false): a cell with no Cat coordinate is not that item. Under route-then-aggregate that arm is reached only where a record genuinely lacks the coordinate, because the fold hands the expression a record key. Evaluated bare, outside a SUM, it degrades exactly as Formula::filter does — which is why a fitted tree is always emitted wrapped.

-

#PageAxis — what constrains, and what the user may turn (improvise-p1ym)

-

GridLayout::page_coords answers two questions that gotcha 27 says must not be confused. The bar at the top of a view names every category constraining the visible records, which is View::filtering_cats() — Page ∪ Filter — because a drill fixes its coordinates on Filter and the user should still see them. [/] cycle only what the user may act on, which is categories_on(Axis::Page).

-

Those were two walks before: GridLayout did the first, navigation.rs:: page_cat_data did the second, and each spelled the Page-vs-Filter rule itself. Two spellings of one rule is the shape design-principles §5 calls the disease, and here it had already leaked into the protocol — GridCache::page_coords was a verbatim copy of the layout's list, so a browser handed it would have tried to page a Filter axis.

-

So page_coords is now Vec<PageAxis>, and items is Some exactly when the axis is navigable. The rule lives in the type: a Filter axis carries no item list, so no caller can compose a cycle for one, and PageAxis::cycle() is the one place that pairs a list with the index its selection sits at. page_cat_data and the browser's page_cycle both read it rather than deriving it.

-

Two consequences worth stating. The list is Model::effective_item_names, the same call that already chose the default selection, so the selection and the list it indexes into come from one place by construction — page_cat_data used ordered_item_names before, which for a _Measure page axis meant the default selection could be a formula target [/] could not reach. That incoherence is gone; the cost is that [/] over a _Measure page axis now visits formula targets, which is what the column list shows anyway.

-

And the list is not filtered to visible items. Hidden items are still cycled, because that is what the TUI has always done, and making the browser differ would be the divergence rather than the fix. Changing it is a change to both, together.

-

#The number format — format.rs (improvise-711n)

-

A view's number_format is a printf-style picture, and only that. A , anywhere turns on thousands grouping; a . introduces the precision, which is the leading digit run after the last .; anything following those digits — a conversion letter — is ignored. So ,.0, ,.2, .2, ,.2f and %.2f all name what they look like they name.

-

Why the trailing f is accepted rather than refused. It round-trips into documents today and always has. improv.pest spells the line format_line = { "format: " ~ rest_of_line ~ NEWLINE ~ blank_lines } — the grammar takes the format string as an opaque line, so format: ,.2f is a document the grammar accepts; :set-number-format stores whatever the user typed verbatim (cmd/effect_cmds.rs), and format_md writes view.number_format back out unchanged. Refusing the spelling at load would therefore reject documents this repo's own grammar admits and its own .improv example spells, to no benefit: by gotcha 8 the format is display-only, so misreading it costs a wrong pixel and can never cost a wrong number. A refusal here buys nothing and costs a document.

-

Why printf and not Excel. The two conventions disagree on exactly the spelling that distinguishes them: .00 is precision zero to printf and two places to Excel, so a parser that tried to honour both would have to guess. One is picked, and it is the one every spelling in this repository already uses. Consequences worth stating rather than discovering: 0.00 reads as (false, 0), and an Excel picture such as #,##0.00 reads as (true, 0) — grouped, no decimals.

-

Nothing is validated, deliberately. A spelling the rule cannot read names no precision and formats with none; there is no error channel and no Result, because the reader is display-only and four call sites would have to thread one.

-

What the bug was, and why six tests sat on top of it. The precision was read as fmt[i+1..].parse::<u8>() over the whole tail, so "2f" failed to parse and fell through to unwrap_or(0) — ,.2f displayed zero decimals, in the TUI, in the browser projection and in CSV export. ",.2f" and its ,.3f/,.4f siblings are what this workspace's own fixtures reach for, in about ten tests, and none could see it: they assert the stored string round-trips or use it as an opaque value to dirty a widget, or — in export_csv_quotes_commas_per_rfc4180 — they assert the rendered value starts_with("1,200"), which 1,200 and 1,200.00 both satisfy. A prefix check over a value whose suffix is the thing in question answers a neighbouring question, which is why that one is now an exact equality.

-

Only two of the three consumers are guarded. Reverting the fix fails format.rs's own unit tests and export_csv_quotes_commas_per_rfc4180, and nothing else in the workspace: ui/project.rs::grid_of (the browser projection) and ui/app.rs both call parse_number_format and neither has a test that renders a cell and asserts its digits. That gap is named rather than closed — those files are improvise-command, which was another wave's ground when this landed — and it is the more useful half of this entry, because it says where the next regression would go unseen.

-
-
- - diff --git a/site/docs/code/formula-and-io.html b/site/docs/code/formula-and-io.html deleted file mode 100644 index 5a07ef62..00000000 --- a/site/docs/code/formula-and-io.html +++ /dev/null @@ -1,137 +0,0 @@ - - - - - -improvise-formula and improvise-io — improvise docs - - - -
- -
-

Source: context/code/formula-and-io.md

On this page

improvise-formula and improvise-io

-

The arguments that used to live in the comments of these two crates. Written for a reader with the code open; the code says what, this says why, and why the obvious alternative is wrong. Bug narratives keyed by test name are in context/test-evidence/formula-and-io.md.

-
-

#Pipe quoting has exactly one home

-

improvise_formula::quote owns both halves of the CL-style |…| convention — writing and reading — for both grammars. formula.pest's bare_ident/pipe_quoted and improv.pest's bare_name/pipe_quoted mirror each other character for character, and quote.rs is the one Rust statement of that rule.

-

It lives in improvise-formula because that crate owns the grammar that says what a bare name is, and because every emitter needs it: a name that came from a user's data has to be spelled so the parser hands it back unchanged. improvise-gp re-exports it, improvise-io calls it from persistence/{format,parse}.rs, and parser.rs::unquote_pipe is a thin shim over unescape_pipe_body.

-

Before improvise-i2m5 there were three copies and they had drifted, over the keyword rule below. improvise-io no longer has one — its persistence/quoting.rs is deleted. context/repo-map.md's file inventory kept claiming io "still has its own unguarded copy" long after the deletion — it was corrected in wave 1, but any other doc or comment still saying so is wrong, and that is the shape this particular fact goes stale in.

-

Three entry points, because the callers do not want the same thing:

-
    -
  • quote_name — a name as the grammar wants it: bare when is_bare_name allows, else pipe-quoted.
  • -
  • pipe_quote — unconditional. A .improv text cell goes through this, not through quote_name, because a bare 42 would read back as a number.
  • -
  • unescape_pipe_body — the inverse, over the body between the delimiters (what both grammars' pipe_inner matches). The escape table is written once, in the last two.
  • -
-

#The keyword guard is canonical, user-confirmed — do not revert it

-

An item literally named SUM is written |SUM|. is_bare_name is deliberately stricter than the grammar strictly requires: bare keywords (WHERE SUM AVG MIN MAX COUNT IF IS) do parse today, but only because agg_call needs a ( and PEG backtracking then falls through to ref_expr — an emergent property of rule ordering, not a stated rule. A pipe-quoted name is an identifier in every position, with nothing to fall through, so a name colliding with a keyword is quoted and stops depending on that ordering.

-

IS joined the list with is_expr (improvise-4zl7), and it is the one keyword whose omission would have been silently wrong rather than merely fragile: a category literally named IS written bare turns IS IS East into a predicate on a category called East. Adding a production to formula.pest means adding its keyword here — the two are one change.

-

The cost is worth paying because a ## Formulas line carries formula text verbatim: a name format_md writes is later read by parse_formula. One namespace, one spelling rule.

-

#The escape table, and the one input outside both grammars

-

\| → |, \\ → \, \n → newline; any other \X is preserved verbatim. The trailing arms of unescape_pipe_body are unreachable from either grammar (pipe_inner is ("\\" ~ ANY | !"|" ~ ANY)*, so a backslash is always followed by a character). They are written as a total function rather than a panic so a caller arriving by some other route gets its bytes back. The two pre-i2m5 unquoting copies differed only on a trailing lone backslash, where io returned it and parser.rs panicked; the survivor returns it.

-
-

#Cat IS Item: a coordinate predicate in the expression grammar

-

is_expr = { identifier ~ ^"IS" ~ filter_value } sits in primary before ref_expr, which would otherwise consume the category name and leave IS Item unparseable. A bare name still parses as a reference: is_expr needs the keyword, fails without it, and PEG backtracks into ref_expr.

-

It reuses where_clause's two operands — an identifier for the category and a filter_value for the item — and therefore build_filter, which already reads exactly that pair. The ^"IS" literal is a string in the grammar rather than a rule, so it contributes no pair and into_inner() yields the two operands and nothing else. One builder, one quoting rule, both halves through improvise_formula::quote.

-

The grammar-walking generator needs no new case: it reads formula.pest through pest_meta at test time, so a production added to primary is generated and round-tripped from the day it exists.

-

What the evaluator does with it, and why a fitted tree is always wrapped in a SUM, is context/code/core.md §13.

-

#WHERE carries the same boundary, and the mechanism has to be generatable

-

where_clause and inline_where are compound-atomic with sp+ after the keyword, exactly as is_expr is (improvise-r08u). Non-atomic, pest auto-skipped WHITESPACE after ^"WHERE", so SUM(Rev WHEREAS x = y) read as a WHERE on a category AS — a silently wrong filter where it should have been a syntax error, the same class of defect IS had.

-

Two things decided the shape, and the second is the one worth carrying. A negative lookahead !bare_char with sp* would have been strictly more permissive — it keeps WHERE|Region|=East, which the old rule accepted — but the grammar-walking generator cannot honour a predicate: it walks formula.pest through pest_meta and emits WHEREa="", text the grammar then refuses, so generated_formula_parses goes red. A rule whose only guard is a lookahead is a rule the generator can violate. sp+ is generatable, matches IS, and costs exactly one accepted spelling: a category may no longer abut the keyword, so WHERE |Region| needs its space.

-

Note compound-atomicity alone closes the ticket's own reproducer, because AS x cannot be one identifier once implicit whitespace is gone. sp+ is what closes the residue, WHEREAS=y, and the negative test names both.

-

#A formula target is unquoted, exactly as a reference is

-

parse_formula strips the pipes off the target the same way it does off an Expr::Ref, because the target names a _Measure item. A target that kept its delimiters made Model::add_formula create an item literally called |Total Revenue| — a ghost column the user's real item never matched, and that Expr::Ref("Total Revenue") never resolved to.

-

Both the TUI formula minibuffer (AddFormula) and .improv load go through that one call, so both were affected. The writer emits Formula::raw, never target, so the file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration.

-
-

#The .improv grammar is the single source of truth

-

persistence/improv.pest defines the format; parse.rs is a tree-walker over pest's parse tree, not a line scanner. Adding a format feature means changing the grammar first.

-

The test generator (parse.rs::generator, and its twin in parser.rs::generator for formula.pest) reads the grammar's AST at test time via pest_meta and emits random valid input. A new production therefore needs generator coverage, or the proptests quietly stop describing the format: an Expr variant the emitter does not handle produces nothing, and generated_file_parses then passes over input that never exercised the new rule. formula.pest's generator is explicit about this — lookaheads emit nothing on purpose, and any unused Expr variant falls through the _ => {} arm; if the grammar starts using one, generated formulas fail to parse and the gap surfaces.

-

Two generator details worth not re-deriving. The atomic flag in the formula generator controls whether a Seq inserts a space between children, so non-atomic rules produce the whitespace-separated tokens pest's implicit WHITESPACE accepts. And Choice is 50/50 between two branches, so rules with three or more alternatives (nested Choices) pick deeper branches less often — good enough for random generation, not a uniform distribution.

-

#The tree walkers are infallible by construction

-

Every build_* in parser.rs operates on a Pair pest has already validated. "A formula has exactly one target", "a comparison has lhs, op and rhs" — pest guarantees these before the walker sees the pair. So every .expect(GRAMMAR_INVARIANT) is a bug marker, not a runtime condition: if one fires, the grammar and the walker have diverged. The same reasoning makes the final else arm of each dispatch unlabelled-but-safe (ref_expr is the only remaining alternative of primary, COUNT the only remaining agg_func, >= the only remaining cmp_op).

-

parse.rs's walkers are the opposite and deliberately so: they return Result and name the section in the error, because the .improv file is hand-editable and a failure there is a user's problem, not a programmer's. parse_tree renders pest's error as a located message (line number plus the offending line), because "Parse error: {pest dump}" is unusable to somebody fixing a file by hand.

-

#Two passes, because sections may appear in any order

-

Pass 1 turns each ## section into a plain collector (PCategory, PView) or a flat list; pass 2 builds the Workbook. That split is what makes the format order-independent — a ## View: may name a category defined further down. PCategory::items is a Vec in file order because IndexMap insertion order is load-bearing (gotcha 18), so pass 2 must replay the list as written.

-

Two ## Category: sections naming one category are one category, and the merge is a concatenation performed in pass 1 (ParsedFile::push_category, improvise-349m). Merging in pass 2 — building each section in turn — was not idempotent. A group is created either by the item that names it or by a > line, and build_category creates its own section's item-groups before its own > lines; so the first section's > groups landed ahead of the second section's item-groups, while format_md writes one section with all the items before all the > lines. The reparse then built the other order, and the two format_md outputs differed. Concatenating first makes the built order the written order by construction. Refusing a duplicate header was the alternative and would have made a legal hand-written file an error for no gain.

-

#A section header takes its name as the whole line, totally

-

header_name = { pipe_quoted ~ &NEWLINE | line_name } (improvise-pczv). The &NEWLINE lookahead is load-bearing: without it, ## Category: |A|B parses as A and silently drops B. line_name catches everything else, so a header name can no longer fail to parse. Four rules share it — model_name, initial_view, category_section, view_section — which is why it is one rule and not four copies.

-

The writing half is format.rs::quote_header, and it is deliberately not quote_name: that rule is for a name embedded among other tokens, and applying it to a header would pipe-quote every ordinary spaced title the line carries perfectly well, rewriting the header of every existing document for no correctness gain. quote_header quotes exactly four things, each one thing the reader does:

-
    -
  • the empty name, and any name with leading or trailing whitespace — the reader trims, so the category comes back renamed while every data coordinate still names the untrimmed original: silent orphaning;
  • -
  • a name containing \n or \r — it would start a second line, and Region\n## Data a second section;
  • -
  • a name already spelled |…| — the reader would take the pipes off.
  • -
-

So a header is written raw where a reference is quoted. The grammar accepts both arms, so that asymmetry is a writer convention, not a format rule.

-
-

#Storage is whole-document read/write, and that closed the SQLite question

-

Saving or loading a workbook only ever touches one whole document, so a backend needs no seeking, metadata or partial reads (improvise-6mq). It does need to enumerate and remove documents, because a browser build has no OS file manager behind it — hence list and delete beside read and write (improvise-i34).

-

Paths are opaque document names, not a directory tree: a backend may treat them as filesystem paths, OPFS keys or map entries. The trait doc says "backends that are not trees treat dir as a name prefix" — keep that sentence reachable. It is the sentence that turned out to be right while the OpfsFs impl was wrong, and the bug was found by reading it.

-

Because the trait is two ops over whole documents, an sqlite3_vfs — which needs xRead(offset,len), xWrite, xTruncate, xLock — has nothing to bind to. See gotcha 41 for the measurements that declined improvise-9ix.

-

#storage_contract is a public function, not a test helper

-

It reads like a test helper and it was one: #[cfg(test)] in storage.rs, run against MemoryFs and PhysicalFs so they could not drift. The one backend that most needed it could never be handed to it — OpfsFs is cfg(target_arch = "wasm32") and its sync access handles exist in no host harness — so for that backend the contract lived only in a doc comment.

-

Moving it out of cfg(test) is what lets a browser run this sequence rather than a second transcription of it (improvise-vvl3/xf1). A second copy of a contract is the disease this codebase keeps curing. It returns Result rather than asserting because a failure has to travel out of a Web Worker as a string; the host tests .unwrap() it and get the same message.

-

Two bugs fell out of its first actual execution — see the dir parameter and the index rules below.

-

dir is a parameter, not a constant, because the three backends must agree at every dir, not only at the flat root the browser happens to use (improvise-vhcz). A backend whose list answered in a different path shape passes at Path::new("") — where joining is the identity — and fails at anything else.

-

#impl Storage for Rc<T> lives in improvise-io because of the orphan rule

-

OpfsFs keeps four genuinely async operations — open, attach, refresh, commit_deletes — off the trait, so Storage can stay synchronous (gotcha 28). But App::storage is a Box<dyn Storage>, and a trait object can name none of the four: handing the backend to the App puts the async seam permanently out of reach. The host must hold the concrete backend and give the App a Storage, which means sharing one.

-

impl Storage for Rc<OpfsFs> is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype in the worker and every future worker would have written it again. Here the trait is local, so the impl is allowed, and it is blanket rather than OpfsFs-specific because nothing about it is OPFS-shaped: it is the general statement that a shared handle to a backend is usable as that backend (improvise-hmws). Rc and not Arc because the one caller is a Web Worker — a single thread, and wasm32-unknown-unknown has no others. An Arc impl would be speculative.

-
-

#OpfsFs: why a browser backend can be synchronous, and what the index holds

-

OPFS has two APIs. The promise-based one (getFileHandle, createWritable) is async, but FileSystemFileHandle::createSyncAccessHandle() yields a handle whose read/write/truncate/flush/close are ordinary blocking calls. It exists so wasm can do blocking file I/O — it is what SQLite's wasm build uses — and is available only inside a dedicated Web Worker, which is where improvise-9x6 puts the whole app. So Storage never had to become async.

-

Acquiring a handle is still async, as are directory listing and removal, so the backend splits in two:

-
    -
  • async, explicit: open, attach, refresh, commit_deletes, called by the worker from its message loop where awaiting is free;
  • -
  • sync, via the trait: read/write use an already-attached handle; list answers from an index; delete drops the name from that index and queues the removal.
  • -
-

So a delete is visible to read and list immediately and lands in OPFS at the end of the batch. That ordering is the price of a synchronous trait and is invisible to a single worker. write therefore un-queues any pending delete of the same name: otherwise delete-then-write would be listed and readable and then removed by commit_deletes at the end of the batch — the one ordering the deferral can get wrong.

-

#The index holds documents, not entries

-

Storage::list promises document names, and documents and entries stopped being the same thing the moment attach had to create a file to get a handle for a document that does not exist yet (improvise-vvl3). Three writers, and the split between them is the whole rule:

-
    -
  • attach indexes nothing. What it creates is zero bytes. Indexing there made a fresh origin list demo.improv before anything had been written, so the seed was skipped as "already there" and load_from got an empty buffer.
  • -
  • Storage::write indexes the name (and un-queues a pending delete). A written document is the first thing there is to list.
  • -
  • refresh rebuilds from OPFS, skipping zero-byte entries — and counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle.
  • -
-

holds_bytes is where the last clause lives, and its two failure modes get opposite answers, which is the whole function:

-
    -
  • getFileHandle fails → the name is not a file. keys() yields directories too, and a directory rejects with TypeMismatchError; an entry removed between keys() and here rejects as well. Neither is a document and neither can ever be attached or read, so listing one is the same picker bug this filter exists to remove (Copilot, PR #24).
  • -
  • getFile fails on a real file handle → another context of the same origin holds the exclusive sync access handle (two tabs). An entry somebody else has open is certainly a document, so this fails open. Dropping it would hide a real document; failing the whole refresh would let a second tab break the first one's picker. Over-reporting costs one row — the direction Dirty::of is deliberately sloppy in.
  • -
-

Handles are exclusive per file, so attach closes any handle it replaces and detach releases the lock.

-

#list joins dir like the other backends

-

The join is not cosmetic (improvise-vhcz). MemoryFs and PhysicalFs answer with dir-joined paths — paths a caller can hand straight back to read — and OpfsFs used to answer with bare names. At Path::new("") the two shapes coincide, which is the only dir the worker ever passes, so the disagreement was invisible: a worker that organised documents under docs/ would have got a full picker in every host test and an empty one in a browser, with nothing saying why.

-

dir is decoration rather than a filter, because this backend is flat by construction: entry_name reduces every path to its file name, so docs/a.improv and a.improv are the same OPFS entry and an entry name may not contain a separator at all. That is the trait's own "treat dir as a name prefix" — the doc was right and the impl was wrong.

-
-

#Loading: decompression and decoding are two failures with two answers

-

load_from decompresses, then decodes UTF-8, with a separate Context on each. read_to_string would fuse them, and a valid gzip stream holding invalid UTF-8 would then be reported as unreadable gzip — a wrong diagnosis of a document that decompressed perfectly well. Decoding once, afterwards, also leaves one answer to "is this UTF-8" rather than one per branch.

-

The .gz suffix selects gzip regardless of backend: that is a property of the document name, not of the filesystem (improvise-6mq). Legacy JSON is detected by a leading { and is never written.

-

parse_number_cell returns CellValue::Error naming the offending text rather than a fabricated 0.0. No token the current grammar admits actually fails f64::from_str (overflow saturates to ±inf), but if the two ever drift the mismatch must surface — never silently store a wrong number.

-
-

#Persistence write order and the virtual views

-

format_md writes Views → Formulas → Categories → Data: views first because they are typically small and orient the reader, data last because it is the largest section. The parser tolerates any order (see the two-pass note above).

-

_Records and _Drill are scratch views the app rebuilds on demand. They put _Index/_Dim on axes, and those categories are never written — so persisting the views would produce axis lines naming categories the loader will not find. They are skipped on write, and dropped on load if an older file carries them, rather than reinstating a view the app would rebuild anyway; Initial View then falls through to the default, since the name it pointed at is gone. persisted_initial_view is the writing half: saving from records or drill mode falls back to the first real view rather than naming one the file will not contain (improvise-60z).

-

_Measure persists only its non-formula items — formula targets are rebuilt from the ## Formulas section, so format_md collects the targets and excludes them.

-

Page and Filter both carry the selected item after the axis keyword; the other axes have nothing to select. Axis::Filter round-trips even though it occurs only inside the never-written _Drill view, because the alternative was silently writing a Filter axis as some other axis (improvise-dz8, gotcha 27).

-
-

#Import: order is a correctness property, not tidiness

-

analyzer::analyze_records emits distinct values in first-seen order, never HashSet order (improvise-7yx, gotcha 18). build_model adds items in exactly that order and IndexMap preserves it, so a hash order made the same CSV import to a different document on every run — different .improv bytes, different row order on screen, and a different detected date format, since detect_date_format only inspects the first ten samples and which ten it saw varied. The HashSet in analyze_records is there to deduplicate and never to iterate.

-

#The ambiguous-date heuristic reads American-first, deliberately

-

01/02/2025 parses under both %m/%d/%Y and %d/%m/%Y and nothing in the data can tell them apart. detect_date_format is first-match over DATE_FORMATS, so the American reading wins. That is a defensible answer and not obviously the right one: a European CSV imports with days and months swapped, silently, and the derived _Month/_Quarter categories inherit the error. What makes it tolerable is that the wizard shows the detected format and lets the user change it — the same argument two-digit years already rested on. It is pinned by a test so that changing the answer is a decision somebody makes rather than a side effect of reordering the table.

-

Ruled a known limit (improvise-l8rh, the user, 2026-09-20). Two things the ruling rests on. Changing the default to day-first only moves which half of the world imports silently wrong; and the honest fix is larger than the heuristic — an ambiguous column should surface its ambiguity rather than resolve it, which changes FieldProposal's shape and the wizard step that displays it, the way Dataset::warnings returns a decision the user should hear about as data. Worth doing deliberately, not as a patch.

-

What makes the limit reasonable to live with is that it is now deterministic: analyze_records iterated a HashSet until improvise-7yx, so the ten samples detect_date_format inspects were a different ten every run and a column whose eleventh value ruled out %m/%d/%Y detected a different format run to run. The ten-sample window itself is unchanged and is part of the limit.

-

#CSV parsing

-

Short rows keep every header column, missing trailing fields filled with Value::Null, and each affected column is reported as a warning with a row count. The reader is .flexible(true), so without this a short row parsed cleanly and the trailing columns were simply absent from the record map — no error, no warning, and skewed value counts in field-kind analysis (improvise-k8i).

-

A header name becomes a category name and survives into the .improv file, so anything riding along invisibly in one — a byte-order mark, a stray carriage return — is silent corruption rather than cosmetics. That is why the adversarial inputs are tested with controls: a check that cannot see a BOM where there is one proves nothing.

-

parse_csv_bytes_with_warnings exists so CSV import can come from any Storage backend rather than only a file (improvise-fuj); the path-taking wrappers remain for the native CLI.

-

#The wizard's back path is a history stack

-

advance pushes the step it leaves; previous pops it. Keeping the path actually taken, rather than re-deriving the reverse of each conditional transition, means back can never disagree with how the user really got here (improvise-907).

-

Preconditions live in validation_error() and are checked on the step where the user can still fix them — never deferred to build_model after the final confirm. Two gates earn that: too many categories (improvise-mzv) and no category at all (improvise-gfq); both are fixable with c at ReviewProposals, and both used to refuse only at the last keystroke. MAX_CATEGORIES here mirrors the model's private limit and counts regular categories only.

-

NameModel is terminal for advance: leaving it is a build, not a step change. build() parks the workbook in built on Done; a build failure leaves the user on NameModel with the error. Going back from Done discards built, so accepting an import always reflects the latest field decisions.

-

scalar_name decides what a JSON scalar contributes as an item name. Value::Null — which is also what the CSV parser produces for a blank field — and a blank string are absences, not names; stringifying them used to invent an item literally called "null" (improvise-ych). Every other value keeps its printed form.

-

A record missing a value for an accepted Category field has no place in the grid and is dropped; build_model_with_warnings counts it against the first category it is missing and reports the total, because without a warning that loss is invisible in a thousand-row import (improvise-5j9).

-
-

#CSV export

-

render_csv is separated from writing so the bytes can go to any Storage backend, not just a file (improvise-fuj). It goes through the csv crate rather than joining with commas, and it honours the view's number format — a value of 1200 under a comma-grouping format renders 1,200, and a label like Smith, Jr. splits into two fields, corrupting every row (improvise-1cz).

-
-
- - diff --git a/site/docs/code/gp.html b/site/docs/code/gp.html deleted file mode 100644 index 40ca6783..00000000 --- a/site/docs/code/gp.html +++ /dev/null @@ -1,188 +0,0 @@ - - - - - -gp-tree and improvise-gp — improvise docs - - - -
- -
-

Source: context/code/gp.md

On this page

gp-tree and improvise-gp

-

The reasoning that used to live in the doc comments of crates/gp-tree/** and crates/improvise-gp/**. Written for a reader with the code open: the code says what happens, this says why, and why the obvious alternative is wrong.

-

Bug narratives keyed by test name are in context/test-evidence/gp.md. The condensed version is repo-map gotcha 37.

-
-

#1. Two crates, one seam

-

gp-tree is problem-agnostic: a Tree is a regression tree over a fixed-width f32 feature vector, and the crate owns the representation, the GP operators, simplify, render, one generation (breed), the search (fit), and the optional raw-table bridge (data). It knows nothing about what the features mean or how a candidate is scored — the caller supplies a Spec (per-feature ranges and names) and, outside fit, its own fitness. Determinism is the caller's too: the operators take an Rng trait implemented over a seeded generator.

-

improvise-gp is the one seam between that crate and improvise. It cannot live in gp-tree — that inverts the dependency and drags improvise into a generic GP crate — and it cannot live in improvise-formula, which must not know GP exists. It holds three things:

-
    -
  • lib.rs — the emitter: a fitted Tree as improvise formula text.
  • -
  • dataset.rs — a View's records as the feature matrix a fit consumes, the improvise-side counterpart of preprocess.
  • -
  • fit.rs — one fit end to end: records in, an improvise Formula plus the evidence for believing it out.
  • -
-

fit.rs is here rather than in a Cmd because none of it is terminal-specific. A browser fitting the same measure over the same document must run exactly these steps in exactly this order, or it gets a different tree from the same seed. improvise-command/src/command/cmd/fit.rs is the thin terminal wrapper over it.

-

#2. gp-tree returns data and never prints

-

A decision the user should hear about comes back as data: Dataset::warnings carries the columns preprocess dropped for being entirely missing, fit.rs hands back pooled (prediction, actual) pairs and a score per feature rather than tables. examples/fit_csv.rs owns every println! and every eprintln!, and that is the only reason its output stayed byte-identical across these moves. Adding an eprintln! to fit.rs or data.rs is the regression, not a convenience.

-

The reason is not tidiness: a library does not own its caller's stderr. A TUI writing to an alternate screen, a wasm build, or a caller that wants the notice in its own status line can none of them suppress, redirect or translate an eprintln!. preprocess used to do exactly that, which was correct while this code was examples/fit_csv.rs.

-

improvise-gp inherits the rule. FitOutcome::status_line is a rendering of the outcome data, offered so a second front end does not compose its own — the same argument that put flatten_cat_tree in the shared layer — and it is a returned String, never a println!.

-

#3. The vendored RNG, and why its constants are a promise

-

SplitMix64 is ~20 lines and no dependency. The Rng trait exists so a caller can bring its own generator, and every caller so far wrote out this same one.

-

A fit is not a throwaway: it writes a rule into a user's document, so re-running it on the same data must produce the same rule, and a bug report must be reconstructible from its seed alone. That is worth vendoring rather than depending — the point is reproducibility, not statistical quality, and SplitMix64's is ample for picking nodes and thresholds.

-

SplitMix64::seed() keeps the seed the run started from, unchanged as the generator advances, so a caller can show it and record it. A generator that has advanced past its seed cannot answer that question.

-

next_u64 is public because splitmix64_matches_the_reference_vectors pins it. The constants are the algorithm, not an implementation detail: every seed already printed by a fit and every data::Model seed already saved is a promise that these numbers come back.

-

#4. Spec::ranges is a soundness precondition

-

ranges[i] must be a superset of the values feature i can actually take. A too-narrow range makes simplify prune a branch that is reachable, silently changing the tree's outputs. preprocess derives the ranges from the columns themselves, which is what keeps this true; every_training_value_lies_inside_its_feature_range pins that it stays derived.

-

simplify seeds each feature's feasible interval with its real range rather than ±∞, so an out-of-range threshold prunes. The interval is half-open [lo, hi) but a feature can hit its max, so hi is nudged by EPS = 1e-3 and a split at the max (x < max) stays live. EPS is safely below typical threshold granularity.

-

#5. Row<T>: an invariant made unrepresentable

-

Row<T> pairs a feature vector with what it is supposed to predict. The target is generic because the two supervised shapes differ only in it — Row<f32> for regression, Row<usize> for a class code — and every scoring function then reads one slice instead of two.

-

That is the point of the type. Parallel x/y slices could be handed in at different lengths, and zip answered by silently scoring the shorter prefix while a denominator taken from the longer one made the mean too small. There is no length to get wrong here, so there is nothing to validate and no Result to thread through five signatures (improvise-dkqn, doctrine(an_invariant_is_made_unrepresentable_not_validated) — the same answer prune_axis_items got).

-

The pairing is made at the boundary that builds the data. preprocess walks one row index and the rows live inside Target; a Dataset carrying rows and target as two collections would move the mismatched length one level up, into every caller.

-

mean_over(total, n) is the one place the empty row set is answered. Every mean in fit.rs used to reach its own 0.0 / 0.0 on an empty slice: a NaN that propagates through FitConfig::cost, sorts unpredictably against real costs, and reaches a caller as a number rather than as an error. Scoring no rows finds no error, so the answer is zero, stated once instead of in mse, misclass_rate and standardize separately.

-

Changing any of this needs bit-level evidence. tests/scoring_known_answers.rs pins every scoring path as f32 bit patterns plus a next_u64 witness that catches a changed RNG call order, because the CLI prints three or four decimals and byte-identical output alone cannot see a moved accumulation. See context/test-evidence/gp.md.

-

#6. folds and cv_folds answer different questions

-

folds(n, k) splits 0..n into k pieces after a deterministic shuffle. k arrives from a host — a CLI flag, in this crate's only caller — so zero is a number to answer, not an impossible state: out[pos % 0] is a public library function taking its host down with it. One fold is the answer the shape already gives, and fit spells the same max(1) over restarts.

-

cv_folds(n, k) yields nothing below two. A partition into one piece is still a partition, but a cross-validation over one fold is not a cross-validation: the single fold holds every index, the training complement is empty, and both cross-validators would fit a tree on nothing and then let it predict every row. Yielding no folds makes the pooled pairs empty, which is the true count of such predictions — and no fold is drawn, so the shuffle never touches the caller's generator either (improvise-ry89).

-

complement(n, test) builds a bool mask rather than asking test.contains(i) per row, which was a scan of the fold per row and so quadratic in the row count (improvise-t3ux). It yields the same indices in the same order, which is what every downstream draw from the generator depends on.

-

#7. standardize / denormalize: the affine map must be folded back

-

The crate's leaf scores live in a ±5 range, so a target in the thousands cannot be reached by leaf mutation and standardize is not optional. Its consequence is that the champion predicts standard deviations.

-

fit_csv answers that by printing ≈ score × std + mean beside the tree. A document has nowhere to put that, so improvise-gp::fit_measure folds the affine map back into the leaves with denormalize before a single character of formula text is spelled. Omitting it emits a perfectly well-formed formula whose every cell is out by an affine map — wrong in the quiet way, since small numbers in the right shape look like an answer.

-

denormalize touches leaves only, and that is the whole reason it is sound: standardize rescales the target, so a threshold is already in its feature's own units and re-scaling it would be the bug.

-

#8. RegressionQuality carries its own baseline

-

A tree with a plausible-looking RMSE that is no better than always predicting the mean is not a weak rule, it is no rule — and it is worse than no rule at all, because it is one a user will believe. So the comparison is part of the answer rather than something a caller may forget to compute. r2 is not clamped: negative means the fit does worse than the mean, which is a real outcome, and clamping would make "learned nothing" and "actively misleading" read alike.

-

importance_ranking reports each feature's share of the total importance. The all-zero case returns early rather than flooring the divisor: fit_csv used .max(1e-9), which is right for the only input it ever had and wrong as a contract — a lone importance of 1e-10 then divides by the floor rather than by itself and reports 10%, where the one feature the model uses carries all of it. Importances this small are ordinary, being error differences over a standardized target, so the shares have to sum to 100% at every scale.

-

#9. A fit takes two things to reproduce — and here, four

-

data::Model records seed: Option<u64> and config: Option<FitConfig>, because the same seed under a different population/generations/elite/penalty lands on a different tree (improvise-ksst). Both are Option for one reason: every FitConfig value and the seed 0 are legal, so nothing can double as "unknown", and anything reporting a sentinel would state a seed that never ran.

-

Note #[serde(default)] on an Option field is documentation, not the mechanism — serde defaults it anyway, so deleting the attribute fails no test. A backward-compatibility test that only deletes the attribute is not discriminating; aim it at an absent field loading as the default instead.

-

improvise-gp::FitOutcome carries the whole FitRequest, not the pair. FitRequest is { seed, config, cv_folds, importance_repeats, max_records }, and every stochastic step draws from one generator in a fixed order — cross-validate, then fit, then explain. So cv_folds decides how far that generator has advanced before fit draws from it, and therefore changes the champion and not merely the evidence for it. Recording two of four fields documented the reproducibility contract without meeting it.

-

#10. The emitter: quoting, and the threshold's exact decimal

-

A regression tree is a nested conditional, and improvise formulas already have IF(cond, then, else), so a fit can be written straight into a user's model as an ordinary formula with nothing GP-specific left in the file:

-
Score = SUM(IF(humidity < 77.5, 1.25, IF(outlook IS rainy < 0.5, -0.5, 2)))
-

(An earlier form of that sample sat in improvise-gp/src/lib.rs's module doc inside a ``text fence. rustdoc does not execute a text` fence, so it was prose wearing a fence rather than a doctest, and the two-line cap applies to it like anything else.)

-

Two things in it are improvise-4zl7's, and both are load-bearing. The outer SUM is the route-then-aggregate rule made syntax: a tree is fitted on records, and SUM(e) is improvise's existing word for "fold e over the member records" (context/code/core.md §13). Without it the formula routes on the totals one row up and means something else there; with it, a record row is a one-member fold and answers the rule itself. outlook IS rainy is the one-hot outlook=rainy said as a coordinate predicate rather than as a reference to an item no document has — see §11 for which features get which spelling.

-

Two properties the emitter owes its caller:

-

It parses. Feature names come from the caller's data, so they carry spaces, operators, pipes and keyword collisions. Every name goes through quote_name — improvise_formula::quote, the one home for pipe quoting (gotcha 34); improvise-gp re-exports it because it was part of this crate's public surface. A formula target is unquoted on the way back in: it names a _Measure item, so a target keeping its delimiters writes the fit into an item literally named |Total Revenue|.

-

It routes identically. number_text emits the shortest decimal that reads back as the same f64 the f32 widens to — not the f32's full decimal expansion, which runs to 149 fractional digits, and not a shortest-round-trip-to-f32 rendering. Widening an f32 to f64 is exact, so text that reads back as that f64 makes feature < thresh give the same answer in f64 formula evaluation as in f32 gp_tree::eval. A short rendering (0.1 for 0.1f32) sits up to half an ULP away, and a row whose feature value equals the threshold exactly then falls on the other side of the split.

-

number_text panics on a non-finite value. The grammar spells neither, and both fail quietly rather than loudly: "inf" parses as an f64, so the round-trip loop would return text no formula parser accepts, and NaN matches no width at all. Trees built by this crate's own operators over a finite Spec are finite, so this is a precondition rather than a runtime condition — but see §12, because a .improv cell holding inf makes it reachable from a document.

-

expr_text and mark_features share one precondition and spell it the same way (improvise-hkt6): a bare used[*feat] fails with index out of bounds, which names neither the tree nor the spec. Two sites, one failure, one sentence. used_features is the single walk both the document check and the plan check read, because walking the tree twice two ways is how those answers drift apart.

-

#11. A fit is storable only when the document can name its features

-

preprocess invents Region=North for a one-hot, Cost.missing for a missing indicator, when.year for a date part. Those are encoding artefacts, not items of the document, and Expr::Ref resolves through Model::find_item_category — so a tree splitting on one emitted a perfectly well-formed formula whose every cell then evaluated to Error("ref:Region=North"), with nothing between the fit and the file to say so (improvise-8xhy). improvise-4zl7 closed the categorical third of that by giving the emitter a vocabulary for a coordinate, and by giving the evaluator a rule that makes such a formula mean the same thing at every level of aggregation (context/code/core.md §13).

-

#Features: one entry per feature index, read off the plan

-

Features::of(&Dataset, categories) walks gp_tree::data::feature_roles — the one statement of preprocess's expansion order, which encode_row drives off too — and pairs each role with the name preprocess gave it. A tree's feat indexes Features directly, so nothing anywhere reconstructs index → role from the feature names. Spec::names is &[&str] and could not have carried this; the earlier check matched names against the raw plan instead and said in this section that it was avoiding a second implementation of the expansion. Now there is no second implementation to avoid.

-

Each feature gets one of three Spellings, decided by role and by column, never by the shape of the name:

-
rolecolumnspelling
Numerica measureExpr::Ref(measure)
OneHot { value }a category, value != "(missing)"Cat IS Item
Numerica categoryArtefact(NumericCategory)
DatePartArtefact(DatePart)
MissingArtefact(MissingFlag)
OneHota measureArtefact(ValueOfAMeasure)
OneHot { "(missing)" }a categoryArtefact(AbsentCoordinate)
-

The last five arms were one _ => Artefact until improvise-wd6n, and the wildcard was not merely untidy. The refusal it fed read "a col.missing flag or a date part is an encoding artefact", so three of the five families were refused in words naming something they are not — and a future FeatureRole variant would have joined them silently, declined under whichever family the message happened to mention. The match is now exhaustive, so a sixth role is a compile error rather than a sixth thing that message misdescribes.

-

The two exclusions are not fussiness. A one-hot over a measure column names no coordinate at all — the record's value for that measure is text, and Cat IS Item asks about coordinates. (missing) is the absence of a coordinate, which Expr::Is reads as 0 by §13's unwrap_or(false), so the one spelling that would be wrong is exactly the one omitted. A Numeric role over a category column is the trap the previous version of this section called "the hole a name cannot close": a category whose items all parse as numbers produces a feature named after the category, and Expr::Ref would resolve it to a _Measure item of the same name — a different column. Both halves now fall out of is_category, which is a lookup in the header's own first category_columns entries.

-

Duplicate header names are still refused outright, by no_ambiguous_column_names and before anything else: is_category decides by name, so a name that means two columns is a spelling with two meanings.

-

Dataset::columns is not index-aligned with the header — preprocess continues past an entirely-missing column without pushing a ColumnSpec, so one empty category shifts every later spec left. FeatureRole::column() indexes columns, not the header, so that shift is carried rather than tripped over.

-

#What fitted_formula still asks the document

-

Two refusals, in order. An Artefact is refused with no reference to the document at all — nothing could make Cost.missing spellable. Everything else is asked of the document: a Measure must be find_item_category-resolvable and a Coordinate must name a category the model has. A measure literally named Region=North is a legal document and is accepted, because the check never looks at the name's shape — and that is also why the date parts and the .missing flags stay covered without a second copy of preprocess's naming rules.

-

formula_text still spells whatever it is handed, being the tree's own text; an Artefact reaching it is a precondition failure and panics, the same split number_text makes for a non-finite value.

-

#The two families improvise-wd6n declines, separately

-

Both stay Artefact and both stay refused. The decline is written here rather than left open, because each needs a formula-language change and neither needs an emitter change — and they are answered separately because the vocabulary each wants is different. What changed with the decline is that the refusal now says which, in that family's own words.

-

col.missing is declined because the language has no word for absence. The flag means "this record has no value for measure X". Expr::Ref over an absent cell is an Error, not a zero, so there is no existing spelling to reuse and no way to phrase the question as an expression. Closing it means deciding what an absence is in a language whose aggregates already treat it as "no rows" — and that decision reaches apply_agg, eval_agg and the text-unanimity rule, not just this emitter. It is not worth that for a split a fit happens to like.

-

The date sub-features are declined because improvise has no dates. when.epoch_day, .year, .month, .day, .dow, and with a time component .hour, .minute — gp_tree::data::build_date emits them. A .improv cell holding a date is CellValue::Text; nothing in eval_expr reads one, and there is no date type, no date literal and no date arithmetic anywhere in the crate. Closing this is a type addition to the value language, and the sub-features are the smallest part of it: a user who could split on when.year would immediately want to filter and aggregate by it.

-

Both are visible to the user only as a refusal naming the feature and the reason, which is the right interim: a fit that cannot be stored says so, rather than storing a formula whose every cell is a reference error (improvise-8xhy). improvise-4zl7's route-then-aggregate rule (context/code/core.md §13) is untouched — nothing new is emitted, so there is nothing new for it to hold over.

-

Two of the other three are declined permanently: a one-hot over a measure and the (missing) pseudo-item name no coordinate by construction, so no vocabulary closes them. They are refusals rather than gaps.

-

NumericCategory was not, and improvise-zv8t has closed it. That it was called one is the more useful half of improvise-wd6n: prose declaring something a permanent refusal is what stops anyone filing the ticket, and nothing but a reader could have caught it — the suite was green either way.

-

The accident was one argument. fit_measure handed preprocess an Overrides::default(), so it never said which header columns are document categories, although the very next line told Features::of. preprocess then inferred Numeric → Date → Categorical from the cell values, so a Year category with items 2024/2025 became a numeric column and a category of ISO dates became a date column — and both were then refused as encoding artefacts with Year IS 2024 spellable all along. fit_measure now builds its Overrides from the same header[..category_columns] slice it already hands Features::of, forcing each to Forced::Categorical, so the one place the document's own answer was known is now the only place either caller reads it from.

-

Three things follow, and the last is a cost rather than a win.

-

The date decline above now means what it says. A DatePart could arrive over a category column by exactly this accident, so §11's date family was narrower than it read. It now covers a genuine date measure and nothing else, which is the scope the decline was always argued for.

-

The Artefact arms stay, and stay correct. crate::dataset is pub and takes the caller’s Overrides, and Features::of reads roles off whatever Dataset it is handed — so a caller that builds one without forcing still produces a Numeric role over a category column, and fitted_formula still has to refuse it. The arm is unreachable from fit_measure, which is the only entry point a document goes through; it is not unreachable code, and deleting it would be deriving the classification from one caller’s habits rather than from the roles.

-

Forcing widens the encoding, and that costs a document. A Year category with two items was one numeric feature and is now two one-hots; with a hundred items it is a hundred, and MAX_FEATURES refuses the fit outright.

-

The first draft of this paragraph claimed "no document that fitted before stops fitting", on the reasoning that a tree splitting on such a column was refused as an artefact anyway. That is false, and the review that caught it was of this prose rather than of the diff. The refusal is conditioned on used_features, so a fit that simply never split on the numeric category succeeded — and it is precisely the wide categories that a fit has least reason to split on. Measured, on a hundred records with a hundred-item numeric Year beside a Cost that predicts Score on its own: before, the fit lands on IF(Cost < 48.0…) and Year is never touched; after, the preflight refuses at 101 features. a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it pins both halves, including that the repair the message names really works.

-

It is still the right trade, for a reason that is not "both refuse". The old success was accidental: Year was encoded as a single continuous number over 1900–1999, an encoding that means nothing and that the fit could only use by producing a formula it would then be refused for. The document now gets a refusal that names the repair — mark the category a Label — and marking it restores the pre-zv8t feature set exactly. What is lost is the case where the user wanted neither: a wide numeric category left alone while the fit uses the measures. That is filed as improvise-e8no; the honest fix is a budget that can decline a column rather than the whole table, which is a change to what encoded_width returns and not to this decision.

-

The preflight encoded_width had to learn the same slice, or it counted such a column as one feature and handed the refusal to the exact check after materialising the matrix — which is the side of the cost the preflight exists to stay on (§14).

-

And the refusal's own words must not name a column kind the arm does not know. FeatureRole::Missing is not conditioned on is_category — unlike its Numeric and OneHot neighbours — and dataset.rs writes "" for a record with no coordinate for a category, so a .missing flag over a category is reachable. Its clause therefore says "that column" and not "that measure": naming the wrong kind there would be this section's own charge — declined in words naming something they are not — surviving one level down.

-

#12. A fit is persisted as its formula, and nothing else (improvise-7na)

-

.improv stores the emitted formula and no serialized gp_tree::data::Model. The grammar is unchanged by this decision, deliberately and not by omission.

-

The ticket offered three options: (a) formula only, refit to score new data; (b) the data::Model as a JSON blob; (c) seed + config so the fit reproduces. The answer is (a), with (c) already available in gp-tree for whoever wants provenance later.

-

Against (b), two arguments. The general one is the second half of gotcha 41: a blob is a second copy of information the document already carries — the tree is the formula — and nothing keeps the two in agreement. A user who edits the formula by hand, or a cell it was fitted on, leaves a blob that disagrees with the file it sits in, and a round-trip test over the blob cannot see that, because it never compares the two. (The first half of gotcha 41 does not transfer: an opaque blob inside one new grammar production is not a second implementation of the grammar.) The specific one is layering: Tree and FitConfig are gp-tree types, so improvise-io would gain a dependency on gp-tree and .improv would be obliged to track a third crate's serde layout forever — inverting exactly the dependency improvise-gp exists to keep out of the core.

-

What (b) was for, and why it is not needed. The encoding plan exists so fit_csv --predict can score a row that arrives as untyped CSV text. improvise has no such row: a new row is a record in the same document, and recompute_formulas scores it through the stored formula from its own measures.

-

Why (c) is not written to the file either. data::Model already records seed and config, so reproducing a fit is solved wherever that value is in hand; and unlike the CSV case the training data is the document. What is missing is a reader — writing provenance now would add a grammar production and a round-trip obligation for metadata nothing consumes. Provenance is the fit command's decision, alongside how it words the fit for the user.

-

A premise the ticket got wrong, and it is the bigger finding. "The emitted formula is enough to recompute existing cells" holds only for a fit over numeric measures — §11 is that defect, and it is a defect in what a fit may emit, not an argument for persisting the model.

-

#13. dataset.rs: what a view shows, as a feature matrix

-

The shape is not invented here. Records mode is already a feature matrix: a row is one coordinate tuple — every cell sharing all coordinates but _Measure — and each measure is its own column (gotcha 1). That is exactly "a sample, and its features". So the module enumerates the same rows records mode does, lays them out as the string table preprocess takes, and lets preprocess own type inference, one-hot expansion, imputation and the ranges.

-

What it does not reuse is GridLayout: reaching the records path means putting _Index on Row and _Dim on Column, which is a different view from the one the user is looking at. RecordRow::group is public, so the traversal here is the same two steps the layout takes, against the user's own view.

-

Four selection rules, each of which could reasonably have gone the other way:

-
    -
  • Rows are scoped by View::filtering_cats() — Page ∪ Filter — not by categories_on(Axis::Page). Both constrain which records are visible; only Page is navigable (gotcha 27), and scoping by the navigable half would silently fit against rows a drill had already excluded.
  • -
  • Hidden items scope the rows and the feature columns. The mental model is "fit what I can see", and hiding an item is an explicit statement that it is not of interest. An explicitly named target is exempt: the caller asked for it by name.
  • -
  • Every regular category is a feature, whatever axis it sits on — including Axis::None. A category's coordinate is part of what makes a record distinct, so dropping a None category would leave two different rows with identical feature vectors and different targets: noise the fit can neither learn nor report. Label categories are excluded, as they are from records mode, by being not is_regular — they are high-cardinality per-row text and one-hot into a perfect memoriser.
  • -
  • No formula-derived value ever enters the matrix. Formula targets are not features (a formula is a function of the other measures, so it leaks the target whenever the target is one of its inputs), and a formula target is refused as the target — a formula already is the rule, so fitting a tree to reproduce it from its own inputs is trivially perfect and says nothing. One rule covers both halves, with no dependency analysis to get wrong.
  • -
-

_Measure is a filtering category and must not filter a fit. That exception in filter_coords is not a special case, it is the difference between a grid and a feature matrix: _Measure is the coordinate the columns are spread across, so pinning it to one item is not "which records am I looking at" but "throw away every column except this one". Workbook::new's default view puts it on Axis::Page, so that was the behaviour on a freshly opened document (improvise-vx3).

-

Two smaller decisions. Records are sorted by their coordinate tuple. That began as a workaround for matching_cells walking a HashSet; since improvise-shyt it yields insertion order, so the sort is no longer what buys stability. It is kept because a fit's row order should be a property of the data rather than of the typing history that entered it. And a Number cell is rendered with f64::to_string, never format_f64: display rounding is view-only and must not reach a fit (gotcha 8). An Error cell has no value, so it is spelled as missing and picks up the same imputation and .missing indicator an absent cell would.

-

#14. improvise-gp::fit: budgets, and the enum that refuses to guess

-

#Two budgets, and why the preflight is advisory

-

FitConfig bounds the search; nothing bounded the width, which preprocess can multiply without limit (one column per distinct value of a categorical column). Permutation importance alone is repeats × rows × features tree evaluations, on the thread that draws the next frame.

-

MAX_FEATURES = 64 is comfortably above any hand-built pivot table — this repo's own 18,000-cell benchmark encodes far fewer — and comfortably below the one-column-per-row shape that is really a mis-typed category. The message names the fix (Label), because that is what the user actually wants.

-

encoded_width is the advisory preflight, run on the raw table before preprocess materialises anything. Checking width afterwards guards the wrong side of the cost: a category with a distinct value per record allocates a rows × rows one-hot during preprocessing, so the refusal arrives after the memory and the O(rows²) work have been spent. It approximates preprocess's type inference rather than sharing it. Copying the predicate is the lesser evil; the alternative is exporting the inference and calling this a second implementation of it.

-

The debt that copy carries is that the preflight owes EVERY branch preprocess can take, and it paid two of three. preprocess infers Numeric → Date → Categorical, in that order, and the preflight had no date arm at all: a column whose cells parse as neither numbers nor a forced category fell through to the categorical count. So a measure column holding dates — one distinct timestamp in every row — promised 1 and materialised 5, because build_date emits epoch_day/year/month/day/dow, and 7 once any cell carries a time (improvise-x1ns, second instance, found by ChatGPT Codex on #97). That is an under-count of four or six on a single column, against a budget of 64.

-

The two instances are the same defect one inference-branch apart, which is why the shape is now structural rather than remembered. inferred_kind reproduces preprocess's match arm for arm and returns a Forced, and encoded_width matches that with no wildcard — so a fourth Forced variant is a compile error here instead of silently taking the categorical arm, which is exactly how Date was missed. The .missing flag is added once outside the match, because every branch owes it on the same terms.

-

The count each arm owes:

-
preprocess buildsfeatures
build_numeric1
build_date5, or 7 if any present cell has a time
build_categoricaldistinct present values, +1 for the reserved (missing)
-

plus col.missing wherever any cell is missing, and clamped to at least 1.

-

The two directions of a drift do not cost the same, and this paragraph said they did (improvise-x1ns). It read: the cost of the copy drifting is bounded in both directions by the exact check — over-counting costs a needless refusal, under-counting "only lets the exact check do its job". That is wrong, and it is wrong against the argument two paragraphs above it: the exact check runs on Dataset::feature_names.len(), which exists only after preprocess has materialised the matrix. So it bounds the error message, never the cost — which is precisely the thing the preflight was introduced to bound. Over-counting is cheap; under-counting spends the memory and the O(rows²) work the preflight exists to prevent, and then reports it politely.

-

The live instance was the (missing) column: the categorical arm counted present values only, so a forced category with N distinct present values and one record lacking that coordinate promised N and materialised N+1. has_missing was already computed and used in the numeric arm alone — two arms of one match disagreeing about what a column costs, which is improvise-zv8t's forcing-by-name / budgeting-by-index defect one level in. That one was repaired by making the ordering a data dependency and so a compile error; this one has no such shape available, so the guard is a test that asks preprocess what it actually built rather than asserting a number (the_preflight_counts_the_missing_category_a_forced_column_materialises).

-

The previous wave's review saw a version of this and reasoned it away as "read, not injected" and "bounded by the exact check". Both halves of that are the failure mode this file exists to catch: the first declined to run the experiment, and the second repeated the sentence above instead of checking it. An argument that a cost is bounded is checkable, and this one was checkable the day it was written: encoded_width landed 2026-09-19 and the sentence 2026-09-20, so the code it described was one commit old and one file away.

-

encoded_width remains advisory in the direction that is safe: it may over-count, and a column whose cells are entirely missing is counted as one where preprocess skips it altogether. It must never under-count.

-

That sentence was written as a proposed kb.pl invariant while the tree held a counter-example, in the same change that fixed the categorical one — the date branch was missing the whole time and nothing in the hand-back noticed. An invariant proposed from the defect you just fixed generalises the fix, not the code; the thing that would have caught it is the question "what else does preprocess do that I have not mirrored", which is a question about the other function and not about the bug.

-

MAX_RECORDS = 20_000 bounds the work, which the width bound does not: a one-feature table with a million records still runs cross-validation, the final search and permutation importance over all of them. Measured at 105 ms for 2,000 records and roughly linear, so 20,000 is about a second — where a pause stops being a pause. Refusing is honest, and the limit is the caller's FitRequest::max_records rather than a constant, so a host with somewhere else to run a fit can raise it.

-

FitRequest::default() is the interactive budget: FitConfig::default() is the CLI's 80 members over 600 generations, which is right for a batch tool and wrong for a synchronous command. Measured, release build, on the shape this module's tests use: 6 ms at 12 records, 12 ms at 200, 105 ms at 2,000 — the whole sequence, five-fold CV included. There is deliberately no timing test: a wall-clock assertion is flaky on shared CI and would fail for a reason that is not this code's. Re-measure after changing the numbers.

-

#Forcing a numeric category is a representability choice, not a budget one

-

improvise-zv8t made fit_measure force every document category to Forced::Categorical, and improvise-e8no records the cost: a hundred-item numeric category one-hots to a hundred columns, past MAX_FEATURES, and is refused where inference used to read it as one numeric column and the fit succeeded. improvise-e8no is declined, and the refusal is correct.

-

The ticket rules out making the forcing conditional on cardinality, and rightly: that is inference by another name, and it makes the encoding depend on a budget rather than on what the document says. But the stronger reason to decline is that the alternatives are not merely distasteful — they are unstorable, and the code already says so.

-

A coordinate becomes an Expr only by passing through IS: is_expr is identifier ~ sp+ ~ ^"IS" ~ sp+ ~ filter_value, a single-value equality, and there is no IN and no ordering over a category's items. (where_clause and inline_where are coordinate predicates too, and also single-value equalities — but they gate a formula or an aggregate rather than being an Expr, which is gotcha 37(d)'s distinction and is why IS is the one that matters here.) So whatever a tree says about a category, the emitted formula names its items one at a time.

-

A first draft of this paragraph said that made one-hot the only spellable encoding. That is false, and an adversarial review disproved it by running parse_formula rather than reading the grammar: is_expr is a primary, so it is an Expr yielding 1/0, and cmp_op's operands are full exprs — so an ordinal split is spellable, as a weighted indicator sum (IF(2020*(Year IS |2020|) + 2021*(Year IS |2021|) < 2020.5, …)). The claim is recorded here rather than quietly deleted because it is the shape this file keeps catching: a grammar argument made by reading the rule that looked relevant instead of running the parser.

-

So ordinal is not ruled out by spellability, and is ruled out anyway, for two reasons that survive. It invents a metric the document does not assert — a category's items carry insertion order, not magnitude, so Year < 3.5 means "the first four items as they happen to be ordered" and stops meaning that when someone reorders them. And the width it saves does not stay saved: the encoding would be one feature, but the formula still spells one IS per item, so it trades a MAX_FEATURES problem for a formula-size problem while adding a false order. What the current emitter does with it is narrower and is not the argument: spell() maps FeatureRole::Numeric over a category column to Artefact::NumericCategory, because a bare reference to the category's name would resolve to a _Measure item of the same name (improvise-8xhy) — a fact about the bare-ref spelling, not about ordinals in general. Hashing is ruled out outright: a bucket has no name in the document, so it cannot even be refused informatively.

-

And this is what makes the decline clean rather than resigned. The "fit that previously succeeded" was one of two things, never a third. Either the tree never split on the numeric category — in which case the column contributed nothing to the rule, and marking it a Label gives a fit over the same remaining features, inside the budget, which is exactly what the refusal already advises. Or the tree did split on it, and fitted_formula refused the result as Artefact::NumericCategory at storage time. So no fit is lost that was both storable and used the column.

-

Two things that claim less than a first draft of this paragraph did, both worth keeping. Marking the category a Label does not yield the same fit: the feature count changes, so the RNG draw sequence in random_tree/sample_thresh changes, so the champion for a given seed changes — gotcha 37(b)'s own rule, applied to this paragraph. "The same fit" is a checkable cost claim and it is false; what is true is a fit over the same remaining features. And the dichotomy above is argued, from spell()'s exhaustive match over FeatureRole, not asserted: what a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored asserts is one instance of its second branch, over one three-row fixture. That is the most a test can do here, and saying so is the difference between evidence and a flourish.

-

The test still had no predecessor. NumericCategory had no coverage before it — and it was one of three, not the only one: grepping each Artefact variant and each variant's reason() text, MissingFlag and DatePart are covered by the_missing_flag_and_the_date_sub_features_are_refused_too, and ValueOfAMeasure and AbsentCoordinate have no test at all, before this change or after it. Two families remain uncovered; that is filed, not fixed here.

-

What the decline does cost, stated rather than waved at: a user whose Year category really is a quantity they want fitted as a number has no way to say so through fit_measure. The document says Year is a category of items, and that is what is honoured. The remedy is a document change — Year belongs in _Measure if it is a measurement — and crate::dataset is pub and takes the caller's Overrides, so a host that wants to offer the choice can (gotcha 37d). Offering it inside fit_measure would be a new user-facing control, and nobody has asked for one.

-

The refusal's wording was narrower than the rule and is now aligned in both places that carry it: it said a category with a distinct value per record becomes one column per record, which diagnoses the runaway shape and not improvise-e8no's — a category of modest cardinality that is simply wider than the budget. Both now say a category becomes one column per distinct item, whether its items read as numbers or not, since that clause is the whole of what changed under zv8t.

-

#Quality is an enum, not an Option

-

cv_regress pools no pairs below two folds, and scoring an empty pool gives an R² of exactly zero — which reads as "this rule is worth nothing" when what happened is that nobody checked. Quality::Measured carrying an all-zero RegressionQuality means "this tree is no better than the mean", the loudest thing a fit can say, and it must not be reachable by simply not having asked. So the enum, and a host that wants the numbers has to name the case where there are none.

-

The fold cap is rows / 2, not rows. fit_csv clamps to the row count, which lets two rows become two folds of one: each tree is then fit on a single row — a constant — and the numbers come back meaning nothing while wearing the Measured label. That is the confusion Quality exists to prevent, moved from one row to two rather than removed.

-

#fitted_measure_name — a fit is a rule about the data

-

"{target} (fit)", unless the caller names another. Storing it under target itself would make the formula shadow the very cells it was fitted to: gotcha 7's _Measure items are explicit items ∪ formula targets, so the two would be one item with a computed value, and the user's typed data would stop being visible.

-

The destination is refused when it names any existing item, not only an existing measure. Expr::Ref resolves through Model::find_item_category, which finds a regular category's item before it falls back into _Measure (gotcha 7) — so naming the fit after an item of some category installs cleanly and then makes every later formula that references it read the category coordinate instead. Asking the document the same question Expr::Ref will ask is both stronger than a measure-only check and simpler.

-

#one_line is public because the refusals need it too

-

.improv pipe quoting makes \n legal inside a category or item name, so a document can name a measure that splits the TUI status bar in two. The whole composed status line is flattened once at the end rather than each part being sanitised and one being forgotten.

-

The refusals need it as well, and they are composed by the host: several interpolate a document name, and a failure is when a user is most reading the bar. Flattening only the success path left the failure path able to split it.

-

A related trap with no mechanism guarding it: a rustfmt pass over a multi-line string literal leaves runs of ten spaces mid-sentence in a message a user actually sees — invisible in the source and in every test that only asks whether a substring is present. a_name_containing_a_newline_cannot_split_the_status_line asserts the one-line rule for that reason.

-

#15. Known gaps, filed rather than fixed

-

Closed: aggregate-then-route is not route-then-aggregate (improvise-4zl7). Kept here because the shape recurs. A tree is fitted on individual records, and an improvise formula evaluated at an aggregating CellKey used to aggregate each Expr::Ref first and evaluate the IF once on the totals — the same answer for a linear formula, a different one for anything that routes, so the stored rule meant one thing on a record cell and another one row up. The emitter now wraps every tree in SUM(…) and eval_agg folds a non-Ref inner expression over the member records: context/code/core.md §13. What remains of it is a cost, not a defect — the fold is O(members) per evaluation per fixed-point pass and is unbenchmarked (improvise-9iyv).

-

Two artefact families are still unspellable (improvise-wd6n). col.missing and the date sub-features stay Spelling::Artefact and are still refused. §11 says what each would need; they are independent of each other.

-

A mean-imputed feature is read raw by the formula. preprocess fills a missing numeric cell with the column mean, so a champion may split on a value the training rows had and the document does not. The stored formula reads the document, so that record evaluates to something other than what the fit predicted for it — quietly, since the measure name resolves and only the cell is absent. fitted_formula's check asks whether the document can name the feature, a different question from whether every record has one. Closing it means choosing between refusing any feature with a missing cell — which would refuse most real documents, imputation being why preprocess imputes — and teaching the formula language an IFMISSING-shaped primitive. A product decision of the same kind as improvise-4zl7.

-

A boundary value can route differently in the formula than in the fit. Training narrows each f64 cell to f32; the stored formula compares the original f64 against the threshold. number_text guarantees the threshold reads back as the same f64, so the threshold is exact — but a cell value just below a threshold that f32 rounds to it takes hi in gp_tree::eval and lo in formula evaluation. Pre-existing in the emitter (improvise-izk); the fix, one numeric representation end to end, would rewrite the bit-level characterization tests that are the only evidence the routing argument holds.

-

Quality::Measured is transductive rather than fully held out. Preprocessing runs over all rows before cv_regress picks its folds, so an imputation mean and a categorical vocabulary are learned partly from the rows they are then validated against. And it is wider than imputation: Spec::ranges is computed over all rows and the same spec goes into every training fold, so the thresholds the search samples and the branches simplify prunes are bounded by a held-out minimum or maximum. That reaches a fit over complete numeric measures too — the only shape fit_measure currently stores — so "the case that stores leaks nothing" is wrong. Closing it means building the ranges and the preprocessing plan per training fold and encoding each validation fold with that plan, which cv_regress cannot express: it takes one spec and rows that are already encoded. That is an API change in gp-tree, and it is independent of improvise-4zl7, which turned out to need no change to how cv_regress is fed.

-

Which features a champion picks is a property of the search, which used to mean the same command refused or succeeded depending on the seed. For the categorical family that is gone: a one-hot over a real category is spellable whichever way the search goes. It survives for col.missing and the date parts (improvise-wd6n), and it is the reason those are worth closing rather than living with — a refusal that depends on a seed is not a contract.

-

#16. preprocess: what each column kind expands into

-

Real data is not a matrix of floats. Columns are numeric, categorical or dated; cells go missing; a category is unordered and a < thresh split over its arbitrary codes would be meaningless. So each raw column is inferred (or forced through Overrides) to a ColumnKind and expanded:

-
    -
  • numeric → one feature, missing cells mean-imputed;
  • -
  • date/datetime → ordered parts (col.epoch_day, col.year, .month, .day, .dow, and .hour/.minute when the column has a time), so trend and seasonality are learnable rather than one opaque token per timestamp. epoch_day is Howard Hinnant's days_from_civil; parse_datetime requires a 4-digit year so plain hyphenated numbers ("1-2-3") are not mistaken for dates;
  • -
  • categorical → one 0/1 column per distinct value (col=value), which is the correct treatment of an unordered category — the tree then splits on "is it this value?". A missing cell becomes the reserved (missing) level.
  • -
-

Any column that had a missing cell also emits a col.missing 0/1 indicator, so absence itself is something the tree can split on rather than a silent substitution. A clean column emits none, or every fit would carry a constant feature.

-

The expansion is not reproducible from a fresh table: imputation means and the one-hot value list are properties of the training rows, and a later table may not even contain some of them. preprocess therefore records a ColumnSpec per raw column and encode_row replays it; that pairing of fitted trees with their encoding plan is data::Model. A value never seen in training is simply all-zeros, not a new column.

-

Note for §14's preflight: build_categorical pushes the range (0.0, 1.0) for every one-hot whatever the column contains, so a constant one-hot is not pruned by simplify — it cannot know the column is constant. A test fixture that relies on a degenerate range being pruned is relying on seed luck.

-

#17. fit_csv: the CLI's own documentation

-

examples/fit_csv.rs is the crate's second use case (the first was a game bot), a wholly different domain — the point being that gp-tree knows nothing about features or fitness. Almost none of what it does is in that file: preprocessing is gp_tree::data, the search is gp_tree::fit, and what is left is flag parsing, the demo dataset, and the printing.

-
cargo run -p gp-tree --example fit_csv -- path/to/data.csv
-cargo run -p gp-tree --example fit_csv -- --save model.json path/to/data.csv
-cargo run -p gp-tree --example fit_csv -- --predict model.json path/to/new.csv
-

With no path a built-in categorical dataset (the classic "play tennis" decision-tree example) is fitted, so it runs standalone.

-

Shape. The header names the columns; the LAST column is the target, the rest are features. A numeric target is a regression (one tree, minimising MSE); a categorical target is a classification — one tree per class fit to a 0/1 indicator (one-vs-rest), predicting by argmax.

-

What a fit reports, in order: a generalization metric first — k-fold cross-validation against a trivial baseline (predict-the-mean / majority-class), with R²/RMSE for regression and a confusion matrix plus per-class precision/recall for classification — then the final tree(s) fit on all rows, whose train error is optimistic, then a ranked permutation feature-importance table.

-

Tuning flags. --cv <k> (folds, < 2 disables; default 5), --seeds <n> (keep the best of n GA restarts; default 1), --penalty <λ> (per-node parsimony cost keeping trees small and general; default 0.01), --seed <n> (the RNG seed, decimal or 0x-hex; printed by every fit and saved with --save).

-

Column directives. --ignore a,b drops columns (ids, free text); --numeric / --categorical / --date a,b force a column's type — a numeric-looking zip code that is really a category, say.

-

Messy CSVs. Missing cells (blank or NA/null/?/…) are mean-imputed for numeric and date columns, with a col.missing indicator so absence is itself learnable, and become a (missing) level for categorical columns. Rows missing the target are dropped up front, with a count on stderr.

-

--save <path> additionally writes a JSON data::Model: the fitted tree(s) plus enough of the preprocessing plan to re-encode a fresh CSV's raw cells into the exact same feature layout, and the seed and the FitConfig those flags produced — because the seed alone does not reproduce a fit (§9).

-

--predict <model.json> <data.csv> loads that model and scores each row of a CSV of raw feature columns (same names as training, any order, target column optional and ignored), printing one prediction per row — plus a softmax confidence column for classification.

-
-
- - diff --git a/site/docs/code/kb-tool.html b/site/docs/code/kb-tool.html deleted file mode 100644 index 5ac19681..00000000 --- a/site/docs/code/kb-tool.html +++ /dev/null @@ -1,40 +0,0 @@ - - - - - -The kb tool — improvise docs - - - -
- -
-

Source: context/code/kb-tool.md

On this page

Maintaining the knowledge base

-

prolog-sessions/kb.pl is executable knowledge, checked by CI. This is the argument for how it is written, and why the Prolog MCP stopped being the way.

-

#Why the MCP was retired

-

loadProgram replaces a whole predicate, so adding one clause means re-sending every existing clause of it through an agent's context window — and a short payload silently drops the rest. Three further blockers: saveSession re-adds the MCP's own host_rpc shims and reserializes the whole file, and assertz raises permission_error(modify, static_procedure) against a consulted predicate, so there is no incremental route through it at all.

-

#What replaced it

-

scripts/kb, a shell entry point over scripts/kb-merge.pl.

-

Clauses are read as DATA, never consulted. read_term/3 in a loop yields a list of terms; merging is list manipulation; portray_clause/1 writes them back. Nothing is ever asserted, so the static-procedure wall is not reached and the cost of adding a clause is the clause.

-

Canonical form is read_term in, portray_clause out. This was not designed, it was measured: all 495 facts in the existing file — including every escaped quote — round-trip byte-identically, and the form is a fixed point in one pass. The only change on first use is that rules move from the MCP's two-line layout to one line, which is why the reformat lands with this tool rather than before it: the MCP would re-expand them on its next save and the two would thrash.

-

trealla comes from the dev shell. Reaching it through nix shell per invocation cost more than the work: the tool's first real use timed out past two minutes on a write that takes 0.42 seconds with tpl on PATH.

-

#The guards, and why each exists

-

Every one of these was added because injecting its fault showed the tool accepting something it should refuse. None was found by reading.

-

The round-trip check. After writing, the file is read back and required to be the clauses intended. A serializer bug is the wipe-the-knowledge-base failure with a new owner, and this is the only check that sees it. Note it compares variant equality via numbervars over a copy — reading a term back yields fresh variables, so == compares variable identity and reports every rule as a difference.

-

kb_consistent runs on the CANDIDATE. The file is replaced only after the check passes, so a delta that breaks the knowledge base leaves it untouched rather than requiring a revert.

-

Parse errors are caught explicitly. An uncaught syntax error drops trealla into its REPL, which hangs with a terminal and exits 0 without one — a refusal that reads as acceptance. scripts/kb also closes stdin on every call as a backstop.

-

Every run reaches a halt/1. tpl exits 0 unless a halt is reached: measured on 3.10.3, -g "fail" and -g "throw(boom)" both exit 0, and so do fail,halt and throw(boom),halt, because a goal that fails or throws never reaches the halt beside it. halt(2) alone exits 2, so halt works — the default is the hazard. The goal used is therefore

-
(catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1))
-

which gives 0, 2 and 1 for success, throw and failure. Without it, if ! merge ... reads a crashed merge as a clean one and replaces the knowledge base.

-

Terms must be callable. 42. parses, is not a clause, and reached the knowledge base as a fact whose head is a number. kb_consistent has no opinion about stray terms, so nothing objected.

-

A clause can be retired. kb rm takes keys and refuses if any of them is absent, because a typo'd key would otherwise report success and leave the clause in place. Without it no correction could ever drop a fact — the first thing it was used for was retiring kb_is_maintained_through_the_mcp, whose name was the claim that went false.

-

Duplicate keys are refused by default. A key is the functor plus every argument but the last, so doctrine(Name,_) keys on the name and decision(Id,Key,_) on the pair. replace is the door a correction goes through, and it overwrites in place — a count that grew would be two clauses under one name.

-

#What no test here reaches

-

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.

-

A clause name cited inside another clause's string is unchecked — dropping or renaming a clause silently dangles every clause referring to it (improvise-2f97).

-

A shebang would let kb-merge.pl run directly and collapse the two files into one. It does not work on trealla 3.10.3: #! is a syntax error that aborts the consult, so main/0 is never defined, and tpl wants -- args after the file, which a shebang cannot inject. Worth re-testing if trealla gains script support.

-
-
- - diff --git a/site/docs/code/protocol.html b/site/docs/code/protocol.html deleted file mode 100644 index 34b4540d..00000000 --- a/site/docs/code/protocol.html +++ /dev/null @@ -1,362 +0,0 @@ - - - - - -improvise-protocol — improvise docs - - - -
- -
-

Source: context/code/protocol.md

On this page

improvise-protocol — the wire vocabulary and the portable session slice

-

The arguments that used to live in this crate's comments (improvise-9c8c). Written for a reader who has the code open. Nothing here restates what the code plainly says; everything here is something somebody got wrong first.

-

The crate crosses a process boundary, so it depends on improvise-core and serde and nothing else — no terminal backend, no command layer, no IO. improvise-command depends on this, never the reverse, and that direction is what lets a client hold the view half without pulling in the host. boundary_tests::the_wire_types_need_nothing_but_core_and_serde is the part the compiler can hold; the CI wasm job is the other half, since a crate that pulled in crossterm would stop building for wasm32.

-

Three things live here and it is worth being precise about which travels:

-
    -
  • ViewState (lib.rs) is a projection of the command stream, not a wire message. The server keeps a mirror of each client's view state by applying the same commands the client applies, through the shared reduce_view. It is serialized at exactly one point — the bootstrap Snapshot (improvise-q08). Steady-state traffic is commands only.
  • -
  • Command (reduce.rs) is the wire format, in both directions: user intent client→server, Projections server→client.
  • -
  • ClientFrame/ServerFrame (frame.rs) is the framing rather than the vocabulary: which of the above a given JSON blob is.
  • -
-
-

#1. lib.rs — the portable slice

-

#What may live on ViewState

-

Two properties gate membership, and both are load-bearing (improvise-h0b):

-
    -
  • portable — plain data from the allowlisted crates, no shared-ownership containers. The ViewState: Send static assertion is the compile-time proxy (Rc is not Send); the Deserialize derive rejects it too, serde's rc feature being off. Send does not catch the origin-crate or snapshot-safety halves — check those by hand.
  • -
  • snapshot-safe — a fresh client inheriting the value is correct. Staged uncommitted writes and half-open dialogs are not.
  • -
-

State failing either test lives on App instead, under the session-local residue tag: wizard (an improvise-io type, and a half-open dialog is not something to inherit) and drill_state (holds an Rc, and pending_edits are uncommitted writes belonging to whoever staged them).

-

The constraint this puts on new fields: no borrowed-'static fields anywhere ViewState can reach — Deserialize cannot produce one, and the error surfaces on every enclosing variant rather than at the offending field. That is why MinibufferConfig::buffer_key is a String and not a &'static str (improvise-a2x).

-

#ViewCursor and the cursors map

-

Per-client (improvise-8uz). It used to live on View, inside the shared workbook, which meant one client scrolling moved everyone's viewport. View no longer carries it at all — nothing persisted it, so there was no saved-position behaviour to preserve. The map is keyed by view name so that switching views restores where you were in each one; an absent key means the origin.

-

#ViewState::active_view is the second copy, deliberately

-

Workbook::active_view is the document's; ViewState::active_view is the client's, and it is what keys cursors. The second copy exists because reduce_view runs on a client that holds no Workbook (improvise-cqi). The host keeps the two in step; whether they are allowed to diverge — two clients on different views — is improvise-g0f. See repo-map gotchas 29/36.

-

#AppMode::from_name — not every variant is nameable

-

The list is exactly what improvise-command's registry accepted before the move, carried across unchanged, because changing it here changes what enter-mode accepts in a script. It lives beside AppMode rather than in the registry because the names are part of the keymap vocabulary and the keymap is shared: a browser resolving enter-mode must decode the same spellings, and two tables would be the mirror that move existed to delete.

-

Three variants are absent, for two reasons. ItemAdd resolves its target category from the runtime mode and never from a script line (gotcha 20), so naming it would produce a mode with nothing to add to. Quit and ImportWizard are entered by their own commands — quit and start-import-wizard — which do work beyond changing the mode, so enter-mode would be a way to reach half of each.

-

#Panel

-

Panel::ALL/name()/key() drive the toggle keys for Normal and all three panel keymaps, so a fourth panel reaches every keymap from one ALL entry. name() must spell what parse_panel accepts. Panel::mode() is the inverse of AppMode::panel() for the three top-level panel modes only — the sub-modes (FormulaEdit, CategoryAdd, ItemAdd) map back to their panel but not forwards, which is what keeps a panel's styling active while the user types into its minibuffer.

-

AppMode::hint_text() is checked against the keymaps by every_hint_key_resolves_in_its_mode in improvise-command; Quit alone shows nothing, because it is never rendered.

-

#all() walks a cycle, and its totality is tested rather than structural

-

AppMode::all() and Rejection::all() walk a total, cyclic after(). Both doc comments used to claim a new variant could not be omitted, because after() has no None arm. That is half true and the wrong half. Answering after() for a new variant is compiler-forced; pointing an existing arm at it is not, so a variant nothing names is simply never walked — all().len() came back 17 where the enum had 18, with the suite green (improvise-5ubs). The general statement is doctrine(a_list_checked_for_length_reads_like_a_list_checked_for_completeness): an assert_eq!(cases.len(), 13) catches a case being deleted from a list and never one being absent from it, and the two are written identically.

-

Closing it needs an enumeration independent of after(), and without one the ticket's own first option restates the problem. There is no such enumeration in stable Rust for an enum with data — variant_count is nightly, a derive macro means a new proc-macro crate, and strum's EnumIter cannot construct these variants at all, since AppMode::ItemAdd carries a category name and Editing a whole MinibufferConfig. What there is is serde: the Deserialize derive already builds the complete variant-name list, from the enum definition, maintained by the compiler, and publishes it in its unknown variant … expected one of … message. variants.rs reads it back and diffs it against the names the cycle actually reached.

-

Three things this costs, and they are the reason the module is defensive rather than three lines. The message is parsed, so a serde whose wording changes must fail loudly instead of reporting an empty list that passes every cycle — hence the unknown variant prefix assertion and the names.len() > 1 floor. Both live in parse_expected, a seam separated from the deserialization because a should_panic pointed at a real type reaches only one of the three refusals and all three then shared one panic message, so expected = … could not say which had fired (PR #92 review). Each now carries its own wording and is fed a hand-written mutated message; a happy-path case keeps the three refusals from being the only evidence the parser runs. A variant is identified by its own serialization rather than by Debug, because an externally tagged unit variant is a string and a struct variant a one-key object, and reading a name off Debug would depend on the field list. And it checks only that every declared variant is reached: an after() that is not injective — two arms naming one successor — is a different defect this does not see, and would show up as a shorter cycle only if it also orphaned something.

-

What it does not reach: the fourth instance of this shape, layout_geometry_effects_actually_change_document_config in improvise-command/src/ui/effect.rs, whose assert_eq!(cases.len(), 13) is over a hand-built list of effects, not enum variants, so serde has nothing to say about it. Filed as improvise-26dm rather than bodged, because a fourth hand-rolled attempt in a fourth place is the disease.

-
-

#2. reduce.rs — the wire vocabulary

-

#Derived, not invented

-

Command is not a fresh invention (doctrine(derive_the_enum_from_the_effects)):

-
    -
  • The view half is every EffectKind::ViewOnly effect in improvise-command whose target is the portable slice (decision(improvise-cqi, command_set_derived_not_invented)). Effects touching host-local state (the import wizard, staged drill edits, the storage backend, the layout cache) are deliberately absent.
  • -
  • The model half is ModelCommand: every EffectKind::Model(..) effect, one variant apiece, carrying that effect's own fields (improvise-kmgs).
  • -
-

The count is 29 model effects, minus three: SwitchView, which Command already names and routes, and ApplyAndClearDrill/AcceptWizardModel, which are not addressable (improvise-3x3u). That leaves 26. A variant with no data mirrors a nullary effect whose operand is document state the authoritative App already holds.

-

Two derivation traps worth keeping:

-
    -
  • The obvious awk extraction reports 30 — it picks up HelpPageSet, which is ViewOnly, because that is the last pub struct before #[cfg(test)] and the test module mentions ModelChange::. Brace-match the impl Effect blocks instead (doctrine(derive_then_verify)).
  • -
  • improvise-kmgs derived the set one effect at a time, which was correct per element and wrong for a set: it put SetCell on the wire without its MarkDirty companion, so "save if dirty" never fired (improvise-gv9r). MarkDirty is gone; App::apply_effects now raises the flag off EffectKind::Model(_) directly.
  • -
-

#Why two model effects are key-path only

-

A command on this wire is routed by Sessions::apply with no SessionId, which is correct precisely when the change is addressed to no one session (gotcha 36). ApplyAndClearDrill and AcceptWizardModel fail that test: their operands — drill_state.pending_edits and the built wizard workbook — are session-local residue, private to whoever staged them (improvise-tom1). Unaddressed, client B's ApplyAndClearDrill commits client A's uncommitted typing, and B's AcceptWizardModel replaces the document out of a dialogue B never opened. Both were demonstrated against Sessions::apply before removal.

-

Portability of the command was the criterion when they were kept; addressability is the criterion that matters. Not permanent: multi-client drill wants the addressed form (the variant carries a SessionId, the staging map moves onto the session), which is where ADR 001 layer 2 goes anyway.

-

#Command::SwitchView has a foot in both halves

-

The SwitchView effect wears EffectKind::Model(LayoutGeometry), so the model-half derivation lands on it too — and ModelCommand deliberately does not name it a second time (improvise-kmgs). ModelCommand::ViewBack and ViewForward also move the active view and so carry the same document half.

-

#Command::ResetCursor is an invalidation, never a position

-

Derived from App::reset_cursor, the write TransposeAxes and CycleAxis make after changing the grid's shape (gotcha 24). It is a variant rather than a SetSelected plus two offsets for a reason that is not brevity: what the server says is "your cursor is no longer valid", not "be here". Where a client then puts its cursor is its own business — the model is shared, the UI is not — so broadcasting one client's position to everyone would be the wrong message even though origin happens to be where each of them lands today (improvise-gvnb).

-

#Command::Model is a struct variant

-

So the basis cannot be forgotten. ADR 001's check is worth nothing if a batch may arrive without one, and the only enforceable version of "you must also state X" is not being able to build the message without X. There is no Basis::default() for the same reason — see §5.

-

Note what the struct variant does not buy: it closed omission from the message and did nothing to close ignoring on arrival, which is the half a struct variant is usually reached for. That hole needed a type — Checked/CheckedBatch in improvise-command (improvise-ondj).

-

#ModelCommand::SetCell takes a RecordCell and nothing else

-

A CellKey here would require the client to know the coordinate space — every row and column category plus the page and filter selections (gotcha 27), and in records mode the _Dim column-index convention (gotcha 1) — which is precisely the knowledge the server exists to own. The surrogate is a token to echo back; and because it is a record identity, a client holding one for an aggregate cell cannot build this variant at all (ADR 002).

-

LoadModel and ImportJsonHeadless name paths in the host's Storage, never the client's filesystem (gotcha 2).

-

The variants are grouped by the ModelChange level their effect declares, because that level is what Dirty::of turns into projections (gotcha 35). Adding a variant here means classifying its effect there.

-

#reduce_view

-

The same function runs on both ends — the client folds it to draw, the server folds it over its mirror — so the two cannot drift (improvise-cqq, improvise-m91). Total and deterministic: no IO, no clock, no failure.

-

Command::Project and Command::Model are both no-ops in it, for different reasons. A projection is routed to one widget's WidgetCache instead. A model command is the document's business: what a client must fold arrives as the projections it dirtied, plus — when the active view moved — the SwitchView the host broadcasts.

-

A ModelCommand does not mutate anything by itself: improvise-command's model_effects maps it back to the effect it came from and runs it through the same App::apply_effects the key path uses. Effects stay the only mutation site, and the Dirty is the effect's own EffectKind — never a second classification beside gotcha 35's table.

-
-

#3. render.rs — the fold

-

#There is no aggregate render cache

-

Each widget owns its own cache type and folds its own stream of projections (improvise-rkeq): the grid does not learn that a formula was deleted, and the formula panel does not learn that a cell changed. A Projection is one such update, addressed to exactly one widget, and WidgetCache::apply is the fold.

-

The overlap between TileBarCache and CategoryTreeCache — both carry (category, axis) — is deliberate, so neither is woken by the other's changes. Do not "fix" it by sharing a variant.

-

WidgetCache::apply is total and deterministic, like reduce_view: an update naming something the cache does not have is dropped, never a panic. Projections race on the wire, and a client that crashes on a stale index is a client that crashes.

-

Projection::Basis is not a widget — nothing draws it — but it is folded on exactly the same terms; see §5.

-

#DocumentCache — a widget, not a ViewState field (improvise-z754)

-

draw_title read workbook.model.name, model_state.dirty and model_state.file_path. None was projected, so a browser could not draw a title bar at all.

-

A projection rather than a ViewState field, and the argument is gotcha 30's membership test read backwards. ViewState is the portable session slice: per-client UI a client folds its own commands into. These three are facts about the document, shared by every client and writable only by the server — on ViewState they would be document state wearing a session slice's clothes, and reduce_view would need commands nothing composes. The per-widget cache is exactly the shape for "a local copy of what a widget draws, replaced by a Projection".

-

It is the one widget no EffectKind can gate. dirty is raised by any EffectKind::Model(_) in apply_effects and cleared by effect::Save, which is ViewOnly (gotcha 3) and correctly so. SaveAs moves file_path and is also ViewOnly. So Dirty::of reports NOTHING for exactly the effects that change this widget most visibly, and gating it on Dirty would leave every client showing a [+] for a document that has been saved — the forever-wrong half of gotcha 35. ClientSession::document_update therefore observes: it builds the desired cache and diffs it against the mirror, the same answer record_versions got for the same reason (gotcha 40), and the same reason cursor_epoch beats matching on CycleAxis. The diff, not a flag, is what keeps an ungated widget from projecting on every batch.

-

A save is projected, not silent. Host::save ends with project_all() and the worker re-projects after its own persist(), because both clear model_state.dirty outside any batch Dirty describes. gotcha 45's "answered silently on success — nothing moved, so no widget is dirty" was true until this cache existed and is now exactly wrong: the title bar moved. A test that reaches for Sessions::project_all by hand proves nothing here, because that is the call the production paths omitted.

-

stored_as is a name, never a path. The TUI already drew only file_name(), and a socket server projecting file_path would hand a remote client the host's directory layout — useless to it and a leak. What a client can act on is whether the document has a home at all, which is the fact ClientFrame::Save fails on (NoDocumentPath, gotcha 45), so None carries it. A browser's answer is its OPFS entry name; neither end has to know which.

-

#CellId: the kind of identity is the permission

-

SetCell/ClearCell take a RecordCell and nothing else, Drill takes an AggregateHash and nothing else. A client cannot compose a write against an aggregate or a drill against a stored cell — not because either is checked, but because neither carries an id of the other's type (design-principles §3). It projects a fork the model already makes: EditOrDrill branches on exactly this in the TUI today.

-

An earlier draft made this Option<CellId>, absence meaning aggregate. The enum is better: an aggregate is not the absence of an identity, it is a different kind of identity — and modelling it as None left Drill with nothing to address.

-

A RecordCell is (record hash, measure) because the surrogate names the record, not the cell (ADR 002). That is what lets one identity serve both addressing and staleness: record_versions is keyed by the same hash, so a client's own token is the lookup key for its own version, with no CellKey rebuilt on the hot path. measure is absent for a record carrying no _Measure coordinate at all — the shape add-record-row seeds.

-

#CellId::Derived — a formula owns the cell (improvise-ir7l)

-

A formula target is a real stored coordinate, so the record branch claimed it and every client offered an editor over it. The write landed and was then shadowed forever, because evaluate_aggregated reads formula_cache before raw data — not a revert, a permanent silent shadow.

-

The fix keeps ADR 002's discipline rather than adding a second answer beside it: a formula-owned cell projects a different kind of identity, so the write is unconstructible instead of flagged. Derived carries the formula's target and no surrogate, so there is nothing a client could spend on a SetCell; that is the same reason Aggregate carries an AggregateHash and not a RecordHash.

-

In records mode the question is asked of the record's real coordinate. The synthetic (_Index, _Dim) key names no measure, so formula_for can never match it; GridLayout::record_cell_key is the one mapping from a records position to a model cell, and both cell_id and CmdContext::owning_formula go through it. This is not defensive: a measure can be both an explicit _Measure item and a formula target, and the records + column (improvise-poia) is a one-keystroke way to build one.

-

Two orderings matter in project::cell_id. Derived is asked before the record branch, since the coordinate is stored and the record branch would otherwise win. It is asked after the aggregate branch, because a formula cell that also aggregates is still drillable (gotcha 10) — Aggregate and Derived are not alternatives for the same cell, and reversing the two turns a drillable cell into a dead one.

-

The predicate itself is Model::formula_for(&CellKey) in improvise-core, the one home of "is this cell derived". It already existed three times inside model/types.rs as an open-coded loop; the TUI's EditOrDrill and the producer now call the same function rather than each spelling the rule. The TUI had to change in the same breath: it re-derives from the Model it holds, which is legitimate (it is the authority), but it must give the same answer or the two front ends diverge — design-principles §5.

-

#CellId::Create — the column writes no cell (improvise-poia)

-

The two records-mode creating columns — + and +cat (gotcha 1) — projected None, which means "no projected cell". So a client could not tell a column that creates something from a padded slot, and gotcha 42's conflation had a third instance.

-

The ticket offered two designs and both assumed a cell write. They do not happen: commit.rs::add_measure emits exactly one effect, AddItem { category: "_Measure", item: name }, and add_category emits AddCategory(name) and deliberately writes no coordinate on the current row (improvise-2zq). The record under the cursor is not part of the operation, and neither is any value — what the user types is a name. So there is no "distinct command carrying the record surrogate plus the new measure name" to invent, and no two-command flow to sequence: one existing ModelCommand does it, and the address it needs is the name.

-

What was missing was therefore never an address but a recognition. Create carries the kind and nothing else, because the kind plus the typed name is the whole operand. It is not a SetCell target, so render.rs::editable stays Record-only and record_at_cursor still answers None — both by matching, which the compiler now forces.

-

No client composes it yet, and that is one gap rather than this one: keys.rs::commit returns early for every records-mode commit, because a category column renames every measure cell in the row and that fork needs record_col. The creating columns do not need it, so the early return is broader than its reason — improvise-gcvb, which also records the two things that make it more than a one-liner: an inserted +cat column means the TUI does not advance, and AddCategory can abort.

-

#CellDisplay::id — None is not "aggregate"

-

That is Some(CellId::Aggregate(..)), and the distinction is the whole point of the enum. None means the slot holds no projected cell: a GridCache position padded by fit_to_labels, blanked by a reframing, or a records-mode column that is not a cell to write into at all (a category column renames a coordinate across the whole row, gotcha 1, which is a different intent from SetCell). Conflating the two either hides a cell or offers a write the type system exists to make unconstructible.

-

#CellDisplay::edit_text — the difference corrupts data

-

What an editor opened on this cell should start with: the value as the user last typed it, not as it is drawn. text has been through format_f64, so a stored 1200 is drawn 1,200 under a comma-grouping format; seeding an editor with that and committing it parses as CellValue::Text("1,200"), and the number is gone. Display rounding is view-only and must never be fed back into the model (design-principles §4) — and an editor's seed is exactly a feedback path.

-

It is the projection of App::display_value_at_cursor, which is what EditOrDrill seeds the TUI's buffer from, so both front ends start from one string rather than each deriving one. None where there is nothing to edit: the same set of positions id is None for, plus cells whose value is absent. An error cell carries none, because its text is a diagnostic and not a value.

-

A browser found this: tests/lockstep.rs drove i through the host and the client over the same grid, and they disagreed by a comma.

-

numeric and error are decided by the server for the same reason: it is the side that knows which it was, and re-deriving client-side is how the two ends drift apart. CellDisplay is deliberately not a CellValue — by the time a cell reaches a client the number formatting has already happened.

-

#AxisFrame — the grid's frame, and why it is one type

-

AxisFrame (improvise-anr9/s1xw) replaced a Vec<String> of labels that GridLayout::row_label built by slash-joining a multi-level tuple — the exact rendering two_row_categories_produce_cross_product_labels asserts must not appear. The protocol carried a form of the labels the TUI itself considered wrong, so the browser drew Food/Alice where the TUI drew two header columns, with no groups and no collapse indicators (gotcha 44).

-

It is one type rather than a labels plus a levels beside it, and that is the whole point: two answers to "what are the labels" is the disease this projection architecture exists to cure (improvise-edp). A renderer asks this type and there is nothing else to ask. Note what this cure cost that flatten_cat_tree did not: a Vec<String> had to change type, and the shared derivation could not be added beside the old one.

-

cats is the categories outermost-first; entries interleaves Group(FrameGroup) headers with Item(Vec<String>) label tuples exactly as GridLayout::row_items interleaves them. On the row axis the TUI paints a Group as its own row; on the column axis it paints the runs as spans above the headers. That difference is painting, and it is the only thing that differs.

-

cats may be empty — an axis carrying no category is a real layout (one nameless row or column), not a gap, hence levels() returning at least one. AxisFrame::flat leaves cats empty on purpose, so it is not a way to smuggle a multi-level axis in flattened.

-

FrameGroup::collapsed is on the wire rather than left to the client because it is a fact about the view, which only the server holds. FrameGroup::cat travels so a client can echo it back in ToggleGroup without ever working out which category a label came from — which slash-joined labels made impossible.

-

#AxisFrame: four accessors that are not for a draw loop

-

item, label, shows_level and group_of each scan entries from the start, so asking them per position is quadratic in the frame length and, in the TUI, grows with the absolute scroll offset. That defect was found three times on improvise-anr9. The per-position forms survive for one-off lookups only, and their two-line warnings are the thing standing between a future renderer and a measured regression.

-
    -
  • shows_level is the worst of the four: it materializes every item on every call. It is deliberately not made cheaper, because a fast per-position form is an invitation to the loop header_labels exists to replace.
  • -
  • group_of walks entries twice over, once to place idx and once backwards.
  • -
-

The one-pass forms a draw loop wants:

-
    -
  • header_labels(first_rendered) — every header label with suppression already applied, indexed [data index][level], "" where the label repeats. The suppression rule still has exactly one home (fn shows); this is shows_level applied in bulk, not a second copy of it.
  • -
  • groups() — group_of for every position, in one pass.
  • -
  • shares_ancestry(with) — the TUI's column-header underline: at the leaf level only the selected column matches, at a higher level every column under the same ancestor. There is deliberately no per-position form beside it, because one would be a second answer paid for per (column, level) — two frame scans apiece.
  • -
-

leaf_labels() is not "the labels". It is the innermost label of each item: the single most specific name a position has, what a test or a one-line summary calls a row by. A renderer reaching for it on a multi-level axis is drawing the wrong header, not a shorter one.

-

first_rendered is the first data index actually on screen. That entry always shows its full tuple, or a scrolled viewport loses its group context at the top edge (improvise-jjx).

-

body_from(idx) drops the group header that owns the first drawn item: a frame opening mid-group carries that header so group_of can answer for it, but it is above the first drawn row and must not be painted as one. This is the same cut GridLayout::data_row_to_visual has always made.

-

Two things about its argument, and the first of them used to be the whole story and was wrong. It exists partly because the two front ends scroll differently — a browser's frame is already windowed and passes 0, the TUI's covers the whole grid and passes its row offset (gotcha 44). But idx alone cannot say whether the frame leads the axis, and at the top there is no header "above" to drop: it is content, and a collapsed first group has no other affordance. That is AxisFrame::first and improvise-0o6x — §3a.

-

group_of answers membership, and the paragraph that stood here said the opposite: that it answers "nearest preceding header", deliberately, because that is what the TUI had always painted, and that encoding an end boundary would mean changing AxisEntry in improvise-core and was "filed rather than smuggled into" improvise-anr9. All of that was true when written. The filed ticket is improvise-gd83 and it has landed — AxisEntry::GroupEnd closes a run, groups() clears on it, and the TUI's painted span changed with it. The mechanism and its two emission arms are §3a.

-

That correction is here rather than only in §3a on purpose. This paragraph forbade, in terms, the change §3a describes; a reader arriving at the stale copy first would conclude the rule had been smuggled past its own prohibition. When a fact is stated twice, changing it is a grep (gotcha 48) — and the two copies being in one file is what made this worth saying out loud.

-

#The width rule has one home

-

header_level_widths (improvise-1125): per level, the widest label in display cells plus a gap, clamped to [MIN_ROW_HEADER_W, MAX_ROW_HEADER_W]. It lived twice before — inline in src/ui/grid.rs::render_grid and again in improvise-command::ui::viewport::compute_row_header_width — and a browser sizing its own row header would have been a third.

-

It takes an iterator of borrowed tuples rather than an AxisFrame because its other caller holds a GridLayout and is on the keypress path (reached from App::cmd_context); building a frame there to ask the question would clone every row tuple on every key (raised by Copilot on PR #23). AxisFrame::header_widths is the frame-shaped entry point, derived rather than projected as a field because the frame is the whole input — nothing about the model is needed, so a shared function cannot fall out of step with the data the way a separately-sent field could.

-

#GridCache is viewport-indexed, and GridExtent is not

-

A cache position is a position within the viewport: the client does not know the model's coordinates, and the server is free to change what a row means as long as it says so.

-

GridExtent is how big the whole grid is, of which the frames are one window (improvise-bpq2). It is the one fact a client needs to move its own cursor: row_frame.len() is what this client was sent, so a client holding only its window cannot tell "the last visible row" from "the last row" and cannot clamp — a cursor clamped against the window stops at the bottom of the screen. It is deliberately not framed by the frames, unlike cells, col_widths and totals: it is the size of the thing they are a window onto, so fit_to_labels must leave it alone.

-

For a client whose window covers the whole grid — the TUI, which builds its cache at offsets zero — it equals the frame counts, and that coincidence is exactly what makes a derived version look correct there and fail in a browser. It is a named pair rather than two usize on GridCache, because beside row_frame/col_frame two bare counts read as "how many rows are in the frame", which is the confusion the field exists to remove.

-

GridUpdate::Extent's batch position is not load-bearing the way RowFrame-before-Cells is: it addresses no position, so it cannot overtake or be overtaken. It is emitted first only because outermost-first reads better.

-

#GridCache::totals — empty means "no totals", not "not caught up"

-

Framed by col_frame exactly as cells and col_widths are, with one asymmetry: an empty strip means this grid has no totals to draw (records mode, or an empty grid) and is therefore not padded, where a non-empty one is. Padding an empty one would draw a blank total row under a records-mode grid that has none.

-

The emptiness is the server's answer rather than a client's re-derivation, and the totals must be projected rather than client-derived: the sum runs over every row including the ones this viewport has scrolled past, so a client holding only its window could not compute it.

-

page_coords is framed by nothing — it names categories, not positions — so it survives a reshape that drops every row and column. It carries Page and Filter (View::filtering_cats, gotcha 27): both restrict what is visible, and the bar states what is being looked at, not what the user may page through.

-

#CategoryTreeUpdate::Items and ::Axes have no producer, deliberately

-

document_projections only ever sends Categories(..), which replaces the whole panel. The other two arms are derived rather than invented: the effects that move a category tree come in three shapes — the list reorders (AddCategory/RemoveCategory), one category's items move (AddItem/ RemoveItem), several axes move at once (SetAxis, and CycleAxis through it) — and doctrine(derive_the_enum_from_the_effects) is the same recipe that built ModelCommand. An arm the derivation produces is not speculation just because the producer has not reached for it.

-

What the producer reaches for instead is the coarse arm, on purpose: category_tree_of walks every category once, the panel is small, and Dirty::of cannot say which category moved anyway — an EffectKind says that a batch changed the tree, never which part, exactly as it cannot say which records a CellData batch wrote (gotcha 40). Emitting Items/Axes therefore needs the tree observed and diffed, the way document_update observes the title bar, and that is a bandwidth optimisation for a widget that costs a few hundred bytes. It is not worth doing until category-tree traffic is measured to matter.

-

The contrast with the deleted Invalidated is the point: these are unused, and Invalidated was redundant — its meaning already lived in running code. Unused-but-derived earns a note; redundant earns a deletion.

-

#The fold is not commutative — three rules in apply

-

(a) A Cells update that overtakes its frames is dropped, not guessed at. fit_to_labels truncates to the frames it has. A client must not reorder the stream.

-

(b) A frame change that renames a position blanks the cell under it. fit_to_labels preserves by position, which is only right where the label did not change, so apply calls invalidate_relabelled itself rather than trusting a producer to declare it. It blanks the totals too, because they are contents and a stale sum under fresh cells is the one number a user takes at face value.

-

There used to be a GridUpdate::Invalidated arm meaning "contents stale, frame unchanged", and it is deleted (improvise-n89z). It never had a producer: grid_updates diffs, so it always knows which cells moved and sends Cells, and GridCache::seed compacts. There is no state in which a server knows the contents are stale and not which ones — and if there ever were, the answer is a fresh Snapshot through rebase, not a blanking the client cannot act on. Meanwhile its stated meaning had migrated into running code: invalidate_relabelled blanks exactly what a reframing invalidates, totals included. So it was redundant, not merely unused, and three documents described it as load-bearing, which is worse than silence — a reader reasoning about the fold's guarantees assumed something produced it. the_retired_invalidated_spelling_does_not_parse keeps it from returning as a serde alias.

-

(c) Every arm that touches a frame or the widths re-fits. A renderer zips widths with labels, and a length mismatch indexes off the end. Fitting on the frame arms alone is what let a late ColWidths put the mismatch straight back: a short update keeps each unheard-of column at its own widest level label until ColWidths catches up, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holds Cells.

-

The fallback width is measured in display cells, the same unit header_widths and the authoritative ColWidths use: chars().count() would draw 日本 two cells wide where it needs four, so a cache caught between ColFrame and ColWidths would render the new column too narrow. The materialization inside fit_to_labels is deliberate — AxisFrame::item scans, so asking it per column would make a re-fit quadratic.

-

#invalidate_relabelled — a cats change re-identifies everything

-

A cell's identity is cats zipped with its item tuple, so a change of cats re-identifies every position at once — even the positions whose labels are byte-identical. Region = [North] becoming Department = [North] leaves items equal and names a different cell, so the tuples alone cannot answer this and every surviving position is stale.

-

The sharpest half is invisible: a preserved CellDisplay carries an editable CellId::Record (ADR 002), so a client would go on offering an edit that addresses the record the old coordinate named — a misdirected write, in the same family as gotcha 18's drill snapshot, and one no rendering shows.

-

Otherwise the comparison is on the item tuples, not the entries: a group header appearing or collapsing changes the frame's shape without renaming any surviving position, and blanking on that would throw away cells the very next Cells update would have to resend.

-

Two asymmetries about totals:

-
    -
  • A relabelled column drops its total (improvise-hkt6): the total under it is the sum of the old column. A relabelled row does not, because a total spans every row and is not framed by any one of them.
  • -
  • A recategorization of either axis blanks every total. A column total is a sum over the rows, so re-identifying the rows leaves a number that answers coordinates the grid no longer has.
  • -
-

This is what obliges the producer to diff the totals against the frame the label updates will leave rather than against the mirror they change: a column renamed while its total stayed the same number is blanked here, and a producer comparing mirror.totals would find them equal and send nothing. grid_updates compares framed.totals for exactly that reason. The blanking stays anyway, because rule (b) exists to stop apply trusting a producer to remember — the rule lives on both sides or on neither.

-

LabelAxis is private so the row and column cases share one invalidation rule rather than two copies that can disagree.

-

#Bootstrap is a compaction, not a replay

-

A server does not replay history to a late client — it holds the current state and expresses it as the same updates the steady stream carries, so bootstrap is not a second code path and there is no whole-state shape that could disagree with the incremental one. The property that earns it is GridCache::folded(&cache.seed()) == *cache, and it is the ordering that earns it: the frames frame the cells, so they go first.

-

Snapshot { view, seed } is the only thing on the wire that is not an event, and view is the one point at which ViewState is serialized (improvise-q08).

-

Note the fold tests: asserting fold(prefix) + fold(rest) == fold(whole) is vacuous — it is the definition of a left fold — so the tests that earn their place are the order-sensitivity one and the compaction-equivalence one (doctrine(fold_tests_must_test_order_not_the_axiom)).

-

#CategoryTreeCache carries every category, _-prefixed included

-

improvise-vjms: the TUI panel has always listed them, and a browser panel that quietly did not would be a second, divergent answer to "what are the categories". So the _-prefixed convention does reach the client — it is presentation, and both ends render the same rows from this one source.

-

The trap is _Measure, whose items are derived: explicit items ∪ formula targets (gotcha 7). Whoever builds these rows must go through Model::effective_item_names; reading category("_Measure").items compiles, looks right, and silently omits every formula target.

-

Three mutations reach this panel because the effects are three shapes: adding or removing a category reorders the whole list, adding or removing an item touches one category, and a transpose moves several axes at once without touching membership. Which categories are expanded is ViewState::expanded_cats, per-client, so the flattening is a client-side concern and does not belong on the wire.

-

FormulaCache and ViewListCache take one whole-list update, because add and delete are the only effects that touch them and both are cheap to send whole. Which view is active is not in ViewListCache: that is ViewState::active_view, carried by Command::SwitchView, because it is per-client (improvise-8uz). Likewise which tile the cursor is on is ViewState::tile_cat_idx.

-

#WizardCache has a shape and deliberately no Projection

-

improvise-764, and that absence is the ticket's answer to "where does the wizard state live". A Projection is broadcast to every connected client's widget, which is right exactly when the change is addressed to no one session. A half-open import dialogue is the opposite: it belongs to precisely the session that opened it. That is already settled doctrine elsewhere — ImportWizard lives on App and not on ViewState, and AcceptWizardModel was removed from ModelCommand for this same reason (improvise-tom1) after B was shown swapping the document out of a dialogue B never opened. Putting the wizard on the broadcast stream would have re-created that bug on the rendering side: every client drawing a dialogue one of them opened.

-

So it is a cache in shape and a local render model in fact, built from the authoritative App by the host that holds the wizard. A cache without a Projection is the shape session-local residue takes when it still has to be drawn by two different front ends. It lives here rather than in improvise-command for the flatten_cat_tree reason: the browser client depends on improvise-protocol alone, and when it grows a wizard it must draw these rows, not a second derivation. When multi-client import arrives it wants the addressed form, which is where ADR 001 layer 2 goes anyway.

-

Every field is already render-ready — the widget that used to build these reached through ImportWizard into ImportPipeline, FieldKind, DateComponent and a whole Workbook; none of those cross now. Two substitutions are made here so both ends spell them the same way: an undetected date format reads "?", and the root array path reads "(root)" rather than empty. WizardProposal::kind_label is carried rather than re-derived from kind, so the two ends cannot disagree about what "Measure (numeric)" is called.

-

WizardStepKind mirrors improvise_io::import::WizardStep, spelled again rather than re-exported, because this crate must not depend on improvise-io (the wizard is an io type, gotcha 30) and a client that draws the wizard must still be able to name the step it is on. WizardFieldKind is the semantic half of a colour decision — the terminal mapping lives in draw.rs, exactly as AxisColor/axis_fg does (gotcha 26).

-

#Projection carries no multi-widget variant

-

The envelope a widget subscribes by: it folds the payload of the variant that names it and ignores the rest. There is deliberately no variant carrying several widgets' updates together — that would be the aggregate cache in another form.

-

Nothing is defined for the help overlay: its content is static and help_page is already in ViewState.

-
-

#ColWidths stays diffed against the mirror, and the ticket's reason was wrong

-

improvise-44ad asked whether grid_updates should diff col_widths against the label-folded cache rather than against the mirror, as improvise-hkt6 already obliged for totals. Folding the labels re-fits the widths, so under a fold-relative diff the ColWidths a reframe currently carries becomes redundant and is suppressed. Declined, and the two things learned are worth more than the change.

-

The stated cost does not exist. The ticket says the move breaks a_client_on_a_socket_is_seeded_and_gets_its_edit_back, and that that assertion is §3(c)'s standing evidence — so rewriting it inside a change §3(c) guards would be the improvise-q0vb / PR #12 shape. Measured 2026-09-21 by deleting the re-fit from fit_to_labels: three existing unit tests fail (a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by, a_column_without_its_width_yet_is_measured_in_display_cells, col_widths_always_span_the_column_labels) and the socket test is not one of them. What it actually asserts is that a reframe carried widths — a statement about the producer's diff, not about the consumer's re-fit. §3(c) is triply guarded elsewhere, so the evidence argument was about the wrong test.

-

The real reason to decline is coupling. The widths a label-fold invents are each column's own label width, which is an approximation the consumer applies until the authority catches up. Diffing the producer's output against it makes what the server sends depend on a fallback rule defined in the client. Today both are in this crate and the dependency is invisible; the day a second client implements the fold, the producer's message set silently depends on that client's approximation. The redundant ColWidths per reframe is what buys out of that, and it is the direction Dirty::of is deliberately sloppy in (§5, repo-map gotcha 35): over-sending costs one projection, under-sending leaves a client wrong with nothing in the stream to correct it.

-

So the asymmetry between totals and col_widths is stated rather than discovered. totals is fold-relative because hkt6 found a real hole — a rename that leaves the number equal sends no Totals and the client blanks its own. col_widths has no such hole, because the re-fit is a safe fallback rather than a stale value.

-

What was landed is the half the ticket calls "the better test either way": a_client_never_holds_widths_of_a_different_length_from_its_labels asserts the invariant §3(c) is really about, at every step of a fold that widens before its widths arrive and narrows before they shrink — not merely at the end, which is where a message-count assertion looks.

-

#GridCache::records — what kind of grid this is (improvise-e5pf)

-

The host answers "is this the wide records layout" with GridLayout::is_records_mode(), which reads the axes. A thin client has no GridLayout, and it was answering the neighbouring question instead — ViewState::mode.is_records() — at three decision points in resolve.rs. The two agree only while the mode and the layout agree, and Command::ChangeMode(AppMode::Normal) is precisely the message that separates them: the host settles Normal against the layout the batch ends on (App::settle_normal_mode), and reduce_view folds the variant it was handed.

-

So the fact is projected, not derived: GridCache::records, carried by GridUpdate::Records. Deriving it on the client from row_frame.cats containing _Index would have been a second implementation of is_records_mode — improvise-edp's disease — and deriving it from the mode is the bug itself.

-

Like GridUpdate::Extent it frames nothing, so apply does not re-fit and its position in a batch is not load-bearing: it describes what kind of grid the frames describe, not any position in one.

-

The settling is observed, never composed — and the first version of this fix got that wrong. It settled at each composition site, in resolve::changed_mode, against the grid the client held at the moment it resolved the key. ChatGPT Codex found the hole on PR #98: switch_view_at composes [SwitchView(target), ChangeMode(Normal)], so the settling read the outgoing view's grid and records → pivot emitted RecordsNormal while pivot → records emitted Normal. The drift the fix existed to close, by a second door.

-

The unreachability argument the first version leaned on covered the toggle route only — R is a stranding decline in the browser — and records-ness is auto-detected from the axes (gotcha 1), so a view switch changes it with nothing in the batch saying "records". doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated).

-

So changed_mode is deleted, every site emits the plain AppMode::Normal, and ClientState::fold settles after every fold against the grid it holds: self.view.mode = settled_mode(&self.view.mode, self.grid.records). That is route-independent — it needs no enumeration of composition sites, which is the part nothing checks — and it is the same shape the repo already prefers elsewhere: cursor_epoch over matching on CycleAxis (gotcha 24), document_generation over matching on LoadModel (gotcha 39), the dirty flag over a table of command variants (gotcha 45).

-

settled_mode is the one rule, in improvise-protocol, and all three ends call it: ClientState::fold, ClientSession::fold (the server's mirror, so the two cannot diverge in a field nobody currently reads) and App::settle_normal_mode. App::normal_mode is gone — it was that function's host-side duplicate. It settles the Normal pair and nothing else: Editing/RecordsEditing pass through untouched, which is exactly why a records decision asks the grid and never the mode.

-

There remains a window, and it is inherent: between the local optimistic fold of a SwitchView and the arrival of the target view's projection, the client holds the outgoing grid and cannot know the target's records-ness. The mode settles the moment the projection lands. No design removes that window — read-time derivation has exactly the same exposure, since it would read the same stale grid — so the honest statement is that the mode settles when the grid does, on both ends.

-

The three decision points ask state.grid.records. What that buys concretely: the commit at a records cell declines rather than composing typed_cell_command against a grid whose columns are [coords…, +cat, measures…, +] (gotcha 1), which was a wrong write and strictly worse than the black hole it was written to produce.

-

#§3a. The frame's group boundaries and its position in the axis

-

Two facts AxisFrame gained after improvise-anr9, both about group runs.

-

FrameEntry::GroupEnd closes a run. group_of used to answer "nearest preceding header", which is not membership: expand_category emitted a header on entering a group and nothing on leaving one, so an ungrouped item after a grouped run inherited it (improvise-gd83). The §3 paragraph that recorded the old rule — and forbade this change — is corrected there, not merely superseded here. AxisEntry::GroupEnd is the boundary, carried onto the wire as FrameEntry::GroupEnd; groups() clears current on it and group_of stops walking. It is emitted only on Some -> None, never between two groups — the next header already ends the previous run — and at the end of an expansion whose last item was grouped, which is the across-prefix arm cross_product creates.

-

Nothing draws a GroupEnd. Both front ends skip it and it consumes no data index, so it is invisible except through group_of/groups.

-

AxisFrame::first is the absolute data index of the frame's first item. The windowing rule carries the nearest header above a scrolled window so group_of can answer, and body_from drops it on the way to the screen. At the top of an axis there is no "above", so that same leading header is content — a collapsed first group has no other affordance (improvise-0o6x). idx alone cannot tell the two apart, because the browser's frame is already windowed and always passes 0; first + idx == 0 can.

-

The TUI builds over the whole grid so first is always 0 there, and the browser's comes from the projected window. A frame built by hand in a test must set it, which is why it is a plain field rather than derived.

-

It carried #[serde(default)], which was a defect rather than a decision and is now gone (improvise-1wl0): first: 0 is not an absence but the claim "this frame leads the axis", which is the claim body_from acts on — so a frame that lost the field deserialized as leading, and a scrolled browser drew the carried header above its first row. Same argument as Basis having no Default: a zero-valued claim that happens to be true in the common case is a forgeable bypass rather than an absence.

-

Dropping the attribute was preferred to Option<usize> or a two-variant enum, and the reason is about the wire rather than the type. There is exactly one production producer (project.rs::axis_frame, which reads window.start), so a frame is never partially constructed and "unknown" is not a state any honest peer can be in — making it representable would oblige body_from to answer for it, which is the same question one level down. A missing field is now a loud parse failure at the transport, where a wire-format skew belongs. No skew ships today, both ends being one workspace at one commit; this is about the day there is one, and about the attribute reading as a considered compatibility decision when it was not.

-

The Default derive still yields first: 0, and that is not the same hole: an AxisFrame::default() has no entries, so body_from returns an empty slice on either arm and the claim is vacuous. The hazard was specifically a populated frame acquiring the claim by omission.

-

group_runs / group_run_heads are the coalescing rule's one home. The TUI and render.rs::col_groups each coalesced adjacent columns sharing a group, kept in step by a comment (improvise-8qlr). The TUI's one divergence is that it labels its first visible column even mid-run, so a scrolled grid does not draw an unlabelled group line.

-

A run is opened by a HEADER, not by a name (improvise-h0iv). Both prior copies coalesced by name alone, and improvise-8qlr preserved that because it was a consolidation: changing the rule in the commit that moved it would have left the agreement test unable to tell "both ends agree" from "both ends changed together". With one home that argument is spent, and the rule can be changed once for both ends — which is the shape to notice, since a consolidation's frozen behaviour reads as decided afterwards and nothing says it was only frozen for the duration of the move.

-

The ticket's own proposed fix does not work, and that is worth more than the fix. h0iv suggested comparing the (cat, name) pair, FrameGroup carrying cat precisely so a client need not work out which category a label came from. But its own reachability case is one category: Month grouped into Q1, nested under Region, giving North/Q1 and South/Q1 adjacent — the two headers are (Month, Q1) and (Month, Q1), equal as values, so the pair separates nothing. the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them pins that, so the next reader does not re-propose it. What separates them is that they are two different FrameEntry::Group entries, and groups() already borrows each from self.entries — so the comparison is std::ptr::eq on the header, which is the literal statement of "the same header opened both".

-

The case that settled the product question was not the span but the indicator. Two adjacent same-named runs might well be one span a user wants spanned, which is why h0iv was filed rather than fixed. What is not arguable is column_groups_coalesce_by_name_as_the_tui_does, whose fixture merged Month/Q1 (expanded) with Fiscal/Q1 (collapsed) and whose own message said the merged span is "drawn with the FIRST one's indicator" — a collapsed group rendered as expanded, with no affordance saying otherwise. A span boundary is a matter of taste; a wrong collapse glyph is a wrong fact. So: two runs. Reversing it is one comparison, and the test is renamed column_groups_coalesce_per_header_as_the_tui_does rather than deleted.

-

#4. frame.rs — one envelope pair, two deployments

-

#The framing adds as little as it can get away with

-

doctrine(derive_the_enum_from_the_effects): inventing wire variants ahead of the mutations that need them produces a protocol nobody can implement.

-

#There were three pairs for two deployments (improvise-xks6)

-

The socket's server side, a hand-kept mirror of it in the browser bundle, and a third pair written for the worker (improvise-tm6) before the projection architecture landed. The question that had to be answered before any of it moved was whether the worker and the socket want the same vocabulary. They do, and the test applied to each difference was whether the argument for it names the thing they differ in. None did — every difference was a date rather than a decision:

-
    -
  • Viewport is deployment-independent. The extent is the one session input no Command carries by construction: it is a fact about a terminal or a browser window, it is not in ViewState, and no effect produces it, so no derivation reaches it. A worker hosting the same Sessions owes set_extent the same answer. It is the handshake on both, because a Snapshot seeds the visible cells — one built at extent zero is an empty grid the client would immediately have to ask again for.
  • -
  • Key is deployment-independent too. A keymap binding names an intent resolved against a CmdContext a thin client does not hold (gotcha 38), which says nothing about what the bytes travel over. The socket lacked the variant because key forwarding had never been built on it — a gap (improvise-bpq2), not a design.
  • -
  • Commands(Vec<_>) batches where a singleton cannot. A fan-out must arrive whole and in sequence, because a GridUpdate::Cells that overtakes its frames is dropped, not replayed. That is a property of the fold, not of the transport: an in-process postMessage interleaves exactly as a socket does. Commands(vec![c]) expresses everything a Command(c) variant could.
  • -
-

The worker pair was not merely different but incoherent by omission: written before ADR 001, it could carry Command::Model { basis, .. } and had no Rejected, so a client speaking it composed a claim that could never be answered. That incoherence — not the difference — is the tell for staleness.

-

#The mirror is gone, and so are the two tests that held it honest

-

That deletion was the ticket's measure of success. the_socket_framing_matches_the_server round-tripped hand-listed fixtures through the other crate's types to catch a rename; every_server_variant_is_one_this_crate_mirrors matched exhaustively to catch a variant added on the server and never mirrored. With one definition both are type identity: a rename is a compile error at every call site in both crates, and the un-mirrored variant is not a constructible state. That is stronger than either test, and it is the shape to insist on — if a consolidation leaves one of its compatibility tests alive, the consolidation is not finished (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).

-

What survives is the_retired_spellings_do_not_parse, aimed at the one thing still possible; see context/test-evidence/protocol.md.

-

#ClientFrame::Commands — the frame is the unit of atomicity

-

Not the command (ADR 001, improvise-955n). The host answers for every command in the frame before applying any, so a frame is never applied partially because of a refusal. It used to apply in order and abandon at the first rejection, which left a hole ADR 001's "never applied, not even partially" does not have.

-

The frame states one basis, and it is the only claim a client can construct: a basis is published between batches and never during one, so no client can hold one describing the document as it would be half-way through its own frame. Commands then run in order, each against what its predecessor left, and data dependence between them is expected — a chain may create a record and then write it.

-

But only two of the three tiers are answered once. document_generation and layout_version describe the document, so answering them on the frame's first mutation is right. The record tier is asked of every mutation, because which records are at stake is a property of the command rather than of the frame — skipping it for later commands let one client's chain overwrite a record another had moved, silently. The exemption that keeps data-dependent chains working is narrower than "later commands are not checked": a record that neither resolves nor is claimed is one the frame is about to create, and only that combination is exempt. A record that resolves is answered for however the claim is written. CheckedBatch in improvise-command carries the derivation.

-

All-or-nothing is the contract and clause 4 is not yet delivered. That is the other way a frame stops half-way, and it is not a refusal: a chain whose later command fails at apply time leaves its predecessors applied, because App::apply_effects aborts without undoing (gotcha 21 — an abort is not a rollback).

-

#ClientFrame::Save is a request, not a Command

-

improvise-368h. It carries no path, because a client cannot see the host's storage: effect::Save writes to this host's file_path through this host's Storage (gotcha 2), and which document a host is hosting is the host's own fact. A client naming a path would be naming a namespace it cannot read — the same mistake ModelCommand::SetCell avoids by taking a surrogate rather than a CellKey.

-

It is a frame and not a Command, and the reason is atomicity. It began as a Command, which put a runtime-fallible operation inside a batch Commands guarantees is applied whole or not at all — so [Save, SetCell] reported a failed write and then applied the edit anyway, and a client reading "refused" could not tell which. Saving also fails the test every Command passes: it mutates no shared state and no client's slice, so reduce_view has nothing to fold and Checked has no basis to answer for. Here it cannot be composed with a mutation at all — the batching question stops being a rule and becomes unconstructible — and it sits beside Viewport, the other session input no Command carries.

-

Success is silent: the document did not move, so no widget is dirty and nobody else is owed anything.

-

#ClientFrame::Key — and a correction

-

The doc used to say "the host runs it through the keymap against the authoritative App". It does not. Host::handle answers this arm with an explicit empty vec, because a key resolved on the host moves state no client can hear (a_key_resolved_on_the_host_moves_state_no_client_can_hear): the projection stream carries nothing of the host's own ViewState, so host-side resolution diverges. improvise-bpq2 closes that by avoiding it — the browser resolves keys against the shared keymap (§6) — so an upstream key is a black hole, not a slow path, and every KeyOutcome::Upstream names a binding that does nothing at all in a browser. The variant is kept because the list of keys that still need the host is named in improvise-wasm-client/src/keys.rs, so closing one has to come there and say so.

-

It carries (KeyCode, KeyModifiers) rather than a KeyEvent because KeyEvent is not Serialize — the same pair Command::SetTransientPrefix already puts on the wire.

-

#ServerFrame has three variants because Sessions::apply has three cases

-

Applied::Diff, Applied::Rebased, Applied::Rejected (gotcha 39).

-

Snapshot is boxed: it carries a whole ViewState plus the seed that redraws the document, and every Commands frame would otherwise be allocated at that size for a variant sent once per connection. Serde sees through the box, so the JSON is unchanged. It is sent again to every client whenever the document is replaced, because a client's cursors are keyed by the outgoing document's view names and only a fresh snapshot replaces them (gotcha 24).

-

ServerFrame::Commands carries both of the things a client folds, because on the wire they are the same thing: a Command::Project is a widget update, and any other Command is a peer's accepted move that this client's own view slice must fold too (gotcha 36). Order is load-bearing, so a transport must deliver a batch whole and in sequence.

-

Rejected is addressed to the asker alone — nothing happened, so nobody else is owed anything — and it carries no new state, because the client already holds what is current through the projections it has been folding all along. That is what makes rejection cheap enough to be a normal outcome rather than an error.

-
-

#5. basis.rs — what a batch was composed against

-

#The problem is not ordering

-

The server already serialises every write — one App, one apply_effects — so there is a total order and nothing here is about ordering. What was missing is noticing that a client composed an edit against a picture of the document that has since moved: the same stale-basis shape as the cursor under a reshaped grid (gotcha 24) and the session mirror after a document swap (gotcha 39), one level up.

-

#Three tiers, and the tiering is the whole economy

-
tiermoved bya batch is stale if
document_generationa WholeWorkbook effectit moved at all
layout_versionan observed change to any view's drawn shapeit moved at all
recordsa CellData effect on that recordany record the batch names moved
-

A cell edit is stale when the layout moved, even though no record it names was touched, because the shape its keys hang off changed underneath it. That is ADR 001's class 2 — A removes the category Region while B commits an edit keyed by Region=East — and it is corruption rather than a lost keystroke. Detecting it falls out of the tiering with no lock anywhere: a cell-level lock would not have covered it, because holding Region=East × Revenue does not stop anyone deleting Region.

-

#The client understands none of this

-

A Basis is three opaque tokens — nothing in it is a structure to interpret, the same discipline as the cell surrogate it is keyed by (ADR 002). The server publishes the current one as a Projection, the client keeps it in a BasisCache and hands back claim() when it composes a write.

-

#Basis has no Default and no public constructor

-

A zero-valued basis is not "no claim", it is the claim that the document is fresh — which in a brand-new App is true, so a default would be a forgeable bypass rather than an absence. BasisCache::claim is the only door a client has, and it returns None until the server has published one.

-

Note what that does and does not buy. Basis derives Deserialize because it must cross the wire, and its fields are public, so a hand-rolled client can state any tiers it likes. This is an anti-staleness coordination check, not a security boundary: it closes omission, not invention, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. "The only constructor" is a statement about clients written against this crate, which is still worth having — it makes the honest path the easy one.

-

There is deliberately no flag that skips any of this, because "a mechanism whose only purpose is to violate the invariant is not a feature, it is the invariant deleted with extra steps". A force re-composes the same intent against the current basis and submits a fresh batch; it never applies a stale one.

-

#claim omits rather than invents

-

A record the cache has never heard of is left out, not defaulted to zero. Defaulting would be a claim the client cannot support, and the server would read it as "untouched" — the exact forged-basis case RecordUnstated exists to catch.

-

#The publication is what ADR 001 omitted

-

The ADR says "a batch carries the basis it was composed against" and never says how a client comes to know one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging. That is the one place that document met the code and was wrong. Projection::Basis + BasisCache close it.

-

It is folded like any other widget (improvise-rkeq) — not a widget, nothing draws it, but it subscribes on exactly the same terms, and the alternative was a second server→client channel with its own ordering rules for a payload that changes at precisely the moments a projection already does. The update replaces wholesale, as Projection::Formulas does, so "has it changed" and "send it" collapse into one decision.

-

It is emitted after the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown — the mirror image of the fold's frame-before-cells rule.

-

#Which records are checked is the server's decision

-

reduce.rs::records_named in improvise-command, an exhaustive match with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. Reading the list off the client's claim would be a force by omission: the record tier skipped, with no flag to remove and nothing in the message saying so.

-

#published() takes a BTreeMap

-

So the record list is deduplicated and in a stable order. The server diffs the published basis against what it last sent this client, and a list whose order followed grid traversal would report a change every time a row moved.

-

#Rejection keeps structural and data staleness apart

-

Which is the whole reason ADR 002 put a maintained RecordHash index in DataStore: a regenerate-on-demand scheme could not tell a record that had scrolled out of view from one that no longer existed.

-
    -
  • RecordMoved is class 1. Somebody wrote the record since this client read it. Force is coherent and should be one keystroke: the user has seen the other value and chooses to overwrite it.
  • -
  • RecordGone and LayoutMoved are class 2. There is nothing coherent to force — the key names a coordinate that no longer exists, and applying anyway is exactly the ghost-coordinate corruption this check exists to prevent. Force never creates structure: re-instating a category somebody deleted so an edit can land is a larger intent than the user expressed and must be an explicit action. What the UI owes here is what changed and the concrete alternatives, not a button.
  • -
  • RecordUnstated is a case of its own because "you lost a race" and "you were never in one" are different things to tell a user — and because naming it is what closes the force-by-omission hole. A well-formed client cannot produce it: it composes a write from a cell it is drawing, and the published basis covers every record its grid names.
  • -
-

This is also why the wasm client stores the Rejection itself rather than collapsing it into status_msg: a string cannot carry which record and which kind.

-

#Two classes live in one type

-

Rejection is a refusal of a frame, not of a command — since improvise-368h a ClientFrame::Save the host cannot honour is answered here too, and that is not a refused command at all.

-
    -
  1. Staleness (ADR 001) — DocumentReplaced, LayoutMoved and the three record variants. The only class where force means anything.
  2. -
  3. A request the host cannot honour — NoDocumentPath and SaveFailed (improvise-368h). Nothing went stale; there is nothing to re-compose.
  4. -
-

They share this type rather than getting refusal channels of their own, for the reason the type exists: a refusal is "nothing happened, here is why", addressed to the asker alone, and a second channel would be a second set of ordering and addressing rules for one idea. A third class is coming and is not here yet: a transaction rolled back because a later command in a chain could not be applied (clause 4 of the frame contract). Until rollback exists there is nothing to report, because nothing is undone.

-

NoDocumentPath is decided before anything runs, by the same constructor that answers for a basis, so a save that cannot land is refused rather than attempted. What the asker should do about it differs by front end: a TUI prompts for a path (improvise-lae), a browser worker whose document is its storage entry never sees this at all. SaveFailed carries no message because Rejection is Copy and an I/O error's text is not — the host keeps the text in its own status line, and what crosses the wire is the one fact a client can act on: the document it is editing is not on disk. Reporting a failed write as success is the thing this exists to stop.

-
-

#6. keymap.rs — one table, both ends

-

This used to live in improvise-command, where only the terminal could reach it, and the browser client carried a hand-written classifier answering the same question for the subset it could resolve. That is two definitions of one vocabulary kept in step by a lockstep test over a hand-written list of keys — and doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) says such a pair is one definition waiting to be made.

-

The move is the one gotcha 13 already anticipated: ViewState::transient_prefix stores a prefix as an address rather than an Arc<Keymap> precisely so ViewState could stay free of improvise-command types while the keymap came the other way.

-

#The boundary that did not move

-

A lookup yields a command name and arguments. Turning that into something that runs needs a CmdContext — a Model, a GridLayout, an App — which a thin client does not have, so intent → mutation stays where it was: improvise-command resolves a name through its CmdRegistry into Effects, and a browser client resolves the subset it can compose into Commands.

-

That is a better seam than the one it replaces. The drift risk used to be "which key does what" — ~170 bindings, no direct test, invisible when wrong. It is now "which command names can I execute", which is a list.

-

What stayed in improvise-command, and why each had to:

-
    -
  • Keymap::dispatch and KeymapSet, which pair this table with a CmdRegistry and return Box<dyn Effect>.
  • -
  • SetTransientPrefix, which is an Effect — it writes ViewState::transient_prefix, and Effect is the command layer's trait.
  • -
-

The keymap's own tests live with dispatch in improvise-command/src/command/keymap.rs, for the same reason: they need a registry to check a bound name against.

-

#ModeKey and AppMode are two different enumerations

-

They happen to be the same size (17). SearchMode is a ModeKey and not an AppMode — it is Normal plus search_mode: bool; Quit is an AppMode with no keymap. Do not "fix" one count to match the other. ModeKey::from_app_mode is exhaustive on purpose: a new AppMode without a keymap silently swallows every keypress, so the compiler must flag it.

-

#Lookup fallback order

-

exact(key, mods) → Char with NONE (shift is implicit in the char) → AnyChar → Any → parent. Keymap::with_parent is Emacs-style inheritance, and it is what lets RecordsNormal reach Normal's g/y/Z prefixes.

-

Keymaps::prefix_keymap deliberately goes through lookup, which walks parents, for exactly that reason. Resolution assumes prefixes are flat; prefix_keymaps_are_flat fails the build if a nested one is added.

-

#TextEntry — the spine, welded

-

Every text-entry mode has the same spine: characters append to one named buffer, Backspace pops from it, Esc clears it before leaving. Only the buffer, the exit mode and the commit keys differ. Building them through here means a new text mode cannot accidentally type into one buffer while clearing another, or drop the clear entirely — which would leave the next entry pre-filled with the last one.

-

TextEntry::new takes the AppMode and reads the buffer name off its MinibufferConfig, so the buffer the prompt describes and the buffer the keys edit are the same string by construction rather than by agreement (improvise-k8h) — the keymap never spells a buffer name.

-

commit(key, cmd, args) welds the command to its clear-buffer so no caller can bind a commit without its clear (improvise-w3q). rebind exists for exactly one case: CommandMode's Backspace, which exits the mode on an empty : line instead of popping a character that isn't there.

-

Eight AppMode variants carry a MinibufferConfig, and buffer_of's callers are all eight. Seven are built by TextEntry::new; RecordsEditing is built by TextEntry::with_parent off Editing's map, so a count taken off the ::new call sites reads seven and is not the number of text-entry modes. (See repo-map gotcha 11 — this exact count has already gone stale once.)

-

SearchMode is hand-written on purpose and does not use TextEntry: it has no commit key, and its query is owned by exit-search-mode rather than by clear-buffer. "search" is not in the buffers map but its own ViewState field, and cmd::core::read_buffer/write_buffer are the only code that knows it (improvise-gdl).

-

Category and item add stay open after a commit, so Tab and Enter do the same thing: file this one and start the next. ItemAdd's keymap is built with an empty category name because the category only shapes the prompt text, never the buffer.

-

#PanelKeymap

-

The three panels share a spine — Esc/Tab leave for Normal, arrows and j/k move that panel's cursor, every panel's toggle key is bound so focus can move between them, and : opens command mode. The keys that act on the panel's contents differ in shape, not just in command name (Category's n takes an argument, View has no a, Formula has no Enter), so they arrive through chained methods rather than a fixed-arity signature (improvise-4pg). delete() binds d and Delete together so a panel cannot offer one without the other.

-

bind_panel_toggles is called by PanelKeymap::new and by Normal, so a new panel reaches all four keymaps from one Panel::ALL entry.

-

#Normal-mode bindings worth a note

-

i/a pass their target editing mode as an argument to edit-or-drill, so the command itself stays mode-agnostic; RecordsNormal overrides just those two bindings to say records-editing. Ctrl-S saves to the known path and Ctrl-W asks for one — the pair a user with no file path needs, and what the "No file path" status message points at (improvise-lae).

-
-

#7. viewmodel.rs — the residue between a cache and a renderer

-

improvise-edp asked for a ViewModel layer, a SurfaceViewModel paired with every SurfaceCache. Most of that layer turned out not to exist: after improvise-35e each widget already takes exactly one cache plus the mode, and a cache built per-widget is already render-shaped, so there is nothing left to adapt. What this module holds is the residue — derivations genuinely between a cache and a renderer, because they mix a cache with per-client ViewState.

-

Today that is exactly one: flatten_cat_tree. It lives here rather than in improvise-command because two ends need it and only one of them can reach that crate — the browser client depends on improvise-protocol alone (deliberately; see its Cargo.toml), and move-panel-cursor walks this flattening, so resolving that key client-side (improvise-bpq2) would mean re-deriving it. Two derivations of one flattening is how the two ends drift.

-

CatTreeEntry carries the axis outright, so a renderer needs neither a View nor the cache the entry came from: the entry list is what the panel draws. expanded is ViewState::expanded_cats — per-client, which is why this is derived here and not sent — and the index into the result is ViewState::cat_panel_cursor.

-
-

#8. key.rs

-

These mirror the subset of the terminal backend's key API the command, keymap and effect layers actually use, so those layers carry no terminal-backend dependency. Variant names match crossterm's so call sites only need an import change. The only place both type families may appear is the TUI boundary (src/draw.rs), which converts incoming backend events into these before calling App::handle_key.

-

KeyCode::Null stands in for "no key" (headless command execution) and for any backend key code this crate does not model. KeyEvent is not Serialize — ClientFrame::Key and Command::SetTransientPrefix both carry the (KeyCode, KeyModifiers) pair instead.

-
-

#9. Corrections made while relocating this prose (2026-09-20)

-

Four comments in this crate were wrong about the code and were fixed rather than moved. Recorded because the campaign's own evidence is that a stale argument reads exactly like a live one:

-
    -
  1. frame.rs, ClientFrame::Key said "the host runs it through the keymap against the authoritative App". Host::handle returns an empty vec for that arm. This is the fourth sibling of gotcha 48's "three comments in one range said not built yet" — improvise-bpq2 moved resolution to the client and updated one copy of the argument, not all of them.
  2. -
  3. reduce.rs, every_command_round_trips_through_json said the model half brings "four payload types the view half never carried — CellKey, CellValue, Axis and PathBuf". ModelCommand carries no CellKey at all since ADR 002 (SetCell takes a RecordCell), and CellValue is in the view half, as Command::SetYanked.
  4. -
  5. render.rs, identified_grid carried three paragraphs of doc that describe a different test — "improvise-hkt6 … three columns and a rename in the middle" is a_relabelled_column_drops_its_total, which had no doc of its own, while identified_grid is a two-column fixture. The paragraphs had been merged onto the wrong item.
  6. -
  7. keymap.rs, buffer_of said "the seven modes that carry a MinibufferConfig". Eight do, since SavePrompt (improvise-lae); seven is the number of TextEntry::new call sites, which is the exact confusion repo-map gotcha 11 already warns about.
  8. -
-

Also stale and corrected, without being wrong enough to list above: key.rs's module doc called these "crate-local key types for the command layer" — they are the protocol's, and both front ends use them.

-

records_named lives here (moved from improvise-command::ui::reduce by the review of PR #50). It is &ModelCommand -> Vec<RecordHash> with no App in it, and both ends need the same answer: the server to decide which records to check, a client to decide which to claim. Two copies made the client's half untestable — lockstep.rs restates the basis against its twin host, so it compares the server's answer with itself. One home makes the claim right by construction, and the exhaustive match is the guard it always was: a new record-writing variant fails the build.

-
-

#§8 help.rs — where UI strings live relative to a wire enum

-

Three tickets asked one question three times — improvise-x7sw (which-key hints have no protocol home), improvise-ktv1 (the browser has no help overlay) and improvise-k099 (AppMode::hint_text/label put terminal UI strings on a serde enum). Answering it three times is the disease. The answer:

-

A UI string is keyed by a command name, never by a key, and never hangs off the enum that crosses the wire. AppMode::label and AppMode::hint_text are gone; help::mode_label and help::hint_line replace them. The enum a client deserializes now carries no English at all.

-

And wherever a string names a key, the key is looked up rather than spelled. Hint { keys, command, caption } is the unit: keys come off Keymaps::default_keymaps(), command is the semantic value a localized or non-terminal front end keys off, and caption is the default English — exactly the AxisColor/draw.rs::axis_fg shape, with the decision shared and only the last translation local (invariant(semantic_palette_at_the_core_boundary)).

-

#What that bought, and what it cost

-

It deleted every_hint_key_resolves_in_its_mode and its 60-line tokenizer. That test existed to hold a hand-written string honest against the keymap; the string is derived now, so the property is true by construction. A consolidation that keeps its compatibility test is unfinished (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)), and this is the deletion that measures this one. What replaced it, every_advertised_hint_names_a_binding_that_exists, checks the other direction — a mode advertising a command nothing binds — which was never checkable before.

-

It also deleted Keymap::binding_hints: help::which_key is the one walker, and the binding dump reads it too rather than a second one.

-

The cost is that the hints changed shape. They are longer, they name arrow keys beside hjkl, and two sub-lists in the help pages lost their visual indent. That is the price of the keys being true.

-

#The three rules the rendering follows

-
    -
  1. hint_line drops chorded keys — any key held with a modifier beyond shift, asked of the modifiers and never of the label (improvise-thpe). Hint::keys is (KeyCode, KeyModifiers) and compact takes the pairs, so the string it used to parse is not in scope to parse; Hint::labels and the help pages format last, through the one spelling in keymap::key_label. T advertises tile select; its four Ctrl-arrow aliases would push the rest of the line off the screen and teach nobody anything. A client wanting all of them reads hints(), which keeps them.
  2. -
  3. It runs single-character keys together (hjkl) and slashes the rest (Esc/Tab), which is the idiom the hand-written hints already used.
  4. -
  5. A help-page row whose command resolves to no keys is dropped, so a page can never advertise a key that does not exist. keys() returning Option is what makes that unrepresentable rather than validated.
  6. -
-

#What is not derived, and why

-

The wizard's. AppMode::ImportWizard's whole keymap is one Any binding into HandleWizardKey, which decides per step inside the command — so there is nothing in the table to look up, and its hint is a hand-written tail. That is a real hole and it is where it is visible, not papered over: a keymap cannot describe keys a command resolves privately.

-

gg, yy, ZZ and the ex-commands. A prefix sequence is two addresses and not one binding (gotcha 13), and :q/:w are parsed text rather than keys. Both are HelpLine::Literal, which says out loud that the row is spelled rather than derived.

-

The captions. i:edit where i entered records mode would still pass every test here. A caption is English beside a command name and only a reader judges it — the same limit gotcha 14 recorded for the old hint test.

-

#The browser half

-

RenderModel carries help, help_page and help_titles, and an empty help is what says the overlay is closed — the page asks no question about the mode, the same shape col_groups uses for "no groups". web/app.js paints rows it does not author; src/ui/help.rs keeps its ratatui styles and lost ~310 lines of content that is now shared. Neither end restates what the keys are.

-
-
- - diff --git a/site/docs/code/tooling.html b/site/docs/code/tooling.html deleted file mode 100644 index 286620ec..00000000 --- a/site/docs/code/tooling.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - -The repository's own checkers — improvise docs - - - -
- -
-

Source: context/code/tooling.md

On this page

The repository's own checkers

-

scripts/ holds the gates this repo runs on itself. They are code, and they are held to the rule they exist to enforce: a check nobody has seen fail is indistinguishable from one that always passes, so each of them carries a self-test whose cases are injected faults (doctrine(check_the_checker)).

-

Four docstrings in scripts/check-comment-runs.py point here. This is the file they mean.

-
-

#1. The comment cap

-

check-comment-runs.py fails a .rs file carrying more than two consecutive comment lines. The rule is context/design-principles.md §6: reasoning lives in context/ prose, where it can be read, corrected and audited, not in a comment that nothing checks against the code.

-

The whole difficulty is deciding what is a comment line. Ten silent successes have been found in this one function, every one of them failing in the direction that looks like passing — a file that should have been flagged and was not:

-
    -
  • "test" in line matched #[cfg(test)], so every cfg-gated fixture cleared a gate written to reject exactly those. Anchor the match; an unanchored substring is a filter that over-matches, and a filter that over-matches fails in the direction that looks like success. (The same class as the bd ID audit's exclusion list, and as _JS_TEST's exclusion of .skip/.only.)
  • -
  • A proptest! flag was set and never cleared, so the first proptest! in a file made every later fn — helpers included — count as a test.
  • -
  • An attribute holding a bracket inside a string (#[cfg(feature = "[")]) left the bracket depth unbalanced and exempted the rest of the file. Every question is therefore asked of the scanner, never of the raw line: the scanner knows Rust strings, raw strings, char literals and nested block comments, and the attribute depth is counted over code text only.
  • -
  • A fence that opens and never closes exempted everything after it.
  • -
-

Two rules that are decisions rather than accidents. A fence **suspends** the run rather than ending it, so two lines above a fence and two below is a run of four — a reader meets it as one uninterrupted block. And only a *runnable* fence earns the exemption: rustdoc executes a bare or rust and does not execute text, ignore or no_run, so the rest is prose wearing a fence.

-

#2. The evidence check

-

--check-evidence holds context/test-evidence/ and the tests it describes to each other, in two directions:

-
    -
  • Orphans. Every heading shaped like a test name must name a test that runs.
  • -
  • Unstated. Every test whose doc comment cites context/test-evidence/ must have an entry there.
  • -
-

Neither direction is "every test has evidence". Most tests have none and should not be forced to have one; what is checkable is a claim, and both directions are claims. The failure they close is the same one a dead ticket ID is: a pointer that reads as a promise the thing exists.

-

#A leaf is not an identity

-

The names cargo lists are module paths, and the checker keeps them whole. Six leaf names collide in this workspace — two of them cited in evidence — so reducing a path to its last segment made a heading satisfiable by a test in another module answering another question. Renaming ui::project::tests::a_load_that_fails_is_not_a_rebase, the one its entry actually describes, left the checker at 579 entries, all name a test that runs, exit 0: the surviving ui::host::tests namesake answered for it.

-

So a bare leaf resolves only when it is unique, and an ambiguous one is a refusal naming the candidates — not a warning, because a warning re-creates the problem for anyone who does not read it. The heading is then written with its module path, which is also what makes the entry say which test it is about.

-

Note the two halves are both needed and neither is sufficient. The refusal forces qualification but cannot survive a rename on its own: rename one of a colliding pair and the leaf becomes unique, silently resolving to the survivor. Qualification is what closes that, and the refusal is what makes somebody write it.

-

What stays open is collision across binaries: --list prints module paths with no crate and no binary, so two crates sharing one could not be told apart (improvise-sxsf). Measured 2026-09-21 and still latent — 1444 names, zero duplicate full paths, and zero duplicate (crate, path) pairs.

-

The fix the ticket proposes — --list per package with the crate prefixed — was measured and is declined twice over. It costs 45.2s against 2.4s for the one workspace call (11 members, warm), 18× for an unreachable case, and --check-evidence needs the --ignored twin too, so the real figure is 22 invocations. More to the point it does not close the class it names: a package is not a binary. improvise alone carries five integration test binaries beside its lib, and an integration test's top-level fn lists with no module path at all — 60 of the 1444 names are bare — so -p improvise prefixes all six identically and tests/a.rs::foo still cannot be told from tests/b.rs::foo. A fix that ever becomes worth having is per-target (--lib, --test <name>), not per-package, and the ticket's proposal would have bought 18× for a partial cure.

-

#The names come from cargo, not from the source

-

A heading is diffed against cargo test --workspace -- --list, minus the names that --list --ignored reports. It used to be scraped out of .rs text, and four shapes defeated that, all of them "evidence for a check that never runs" (improvise-rm4s):

-
shapewhy the scrape accepted it
#[test] fn x inside a raw string literalthe scanner reads inside strings
#[cfg(feature = "…")] #[test]the feature is never enabled
#[cfg(target_arch = "wasm32")] #[test]cargo test --workspace is native
#[test] #[ignore]it compiles, and never runs
-

The third is the realistic one here: this repo has real wasm32-only code and a wasm CI job that only runs cargo check. Adopting the stricter source cost nothing — all 539 headings resolved against a real listing on the commit that made the change — which is the moment to tighten a gate rather than the moment to argue about it.

-

Demonstrated before the fix, by injecting all four shapes into src/draw.rs and context/test-evidence/tui.md at once: the previous checker printed 543 entries, all name a test that exists, exit 0. The current one names all four.

-

What this costs: --check-evidence now needs a toolchain and a compiled workspace, about three seconds warm. A missing cargo says so rather than raising. The comment-cap check is unaffected and needs neither.

-

--rust-tests FILE takes a saved listing for a caller that has one, and it requires --rust-tests-ignored FILE: a first version took one file, called cargo_test_names with one argument, and so skipped the ignored rule entirely while printing the same success line as the honest path. A flag that is strictly more permissive and indistinguishable in the log is a bypass, not a convenience — so the pair is mandatory and the success line names its source. CI passes neither and runs cargo itself.

-

A missing context/test-evidence/ is not an early return either. It used to be, and it exited 0 while 170 tests cited a directory that was not there; the walk now yields nothing and every citation is reported unstated, which is loud.

-

Note what the self-test can and cannot carry here. The listing reader is testable from fixtures — a leaf name taken off a module path, an --ignored name subtracted, cargo's own prose not mistaken for a test — and those cases are in SELF_TEST_LISTING. The four source shapes are not: once defined comes from cargo, a fixture asserting "a heading with an empty listing is an orphan" is the same case however the Rust beside it is spelled, and writing four of them would be four copies of one assertion dressed as coverage. The evidence for those is the injection above, against the real tree.

-

What it does not close: citing is still scraped, so a citation inside a string literal would be reported as a test owing an entry. That is a false positive in the safe direction — loud, not silent — and it has no instance.

-

#A demotion is a reviewer's job, not a gate's

-

A heading is classified by shape: a bare identifier is a Rust test, a quoted string a JS test, anything else is prose about the section (Helper — x, Note — y, a file's own scope). So any orphaned heading can be retired by rewriting it with a space and a dash, and nothing can check whether the prose is honest — no mechanism could, because the question is whether a sentence is true (improvise-9za9).

-

Two things make that tolerable. The unsafe direction is the one that fails loudly: prose that looks like an identifier is classified as a Rust claim and reported as an orphan, so the error you can make by accident is the noisy one. And the mechanism is git diff context/test-evidence/ — a demotion shows up there as a ### identifier line becoming prose, in the diff every reviewer already reads. The live instance, PR #69 demoting a heading while PR #72 restored its coverage under another name, is what says a reviewer rather than a gate has to catch it.

-

The printed count of prose headings is a convenience, not a guard: nothing stores the previous count and nobody diffs two CI logs, so a retirement moves a number no one is watching. Saying otherwise would claim a detectability it cannot support, which is the class of defect §7's adjacent-check section is about.

-

#A flag adds a question; it never selects one away

-

--check-evidence used to early-return, so the flagged run answered the evidence question and said nothing at all about the comment-run cap. Three agents hit that independently in one campaign, and in one case a local gate went green while CI went red on a three-line doc comment (improvise-ijql). It is now additive: main runs check_cap first, then check_evidence, and returns the worse of the two.

-

The ticket offered a cheaper shape — leave the early return and print one line saying the cap did not run — on the precedent of PR #83 making --rust-tests name its source. That precedent does not reach this case, and the distinction is worth keeping. A provenance line and a disclaimer are not the same object. Naming which listing the names came from qualifies an answer the run actually gave; saying "the cap did not run" qualifies an answer it did not give, while still exiting 0. Exit 0 is what CI branches on and what a human reads as clean, so a disclaimer leaves the gate exactly as skippable and merely documents the skip. The rule the fix is written to is therefore stated over the exit code: a tree with a long comment run must not pass any invocation that can exit 0.

-

Which invocations those are is a decision, not a sweep. --self-test and --list-doctests are excluded and neither is an exception to the rule: the first scans no tree, and the second is a listing that renders no verdict. Only the two that answer "is this tree clean" are in SELF_TEST_DISPATCH.

-

That table is checked by running the script as a subprocess against a temporary tree, which is the only level at which this bug is visible — every unit in this file was correct, and the defect lived in main's dispatch. Both arms are needed and both were demonstrated: restoring the early return fails DISPATCH: --check-evidence: exited 0 over a four-line comment run, and forcing the cap to always report a violation fails the clean arm for both invocations, which is what stops an always-fail from satisfying the first. The temporary tree also has no context/test-evidence/ and no JS roots, so the evidence half is vacuously clean there and the cap is the only thing that can decide the exit code — the isolation is what makes the assertion name one question.

-

#Two headings, one test

-

An orphan is a heading whose test nobody can find; the mirror image is a test that two headings answer for, and it is invisible to every rule above — each copy names a test that runs, so each copy passes. PR #92 shipped two entries under one heading where the older described a fixture the test no longer had and repeated a claim an adversarial review had measured false. A reader meeting the older one first has no way to know a successor exists two thousand lines down, so the stale copy reads as current. That is worse than an orphan, which at least announces itself as broken.

-

It is a refusal and not a warning, on the precedent improvise-rm4s set for the ambiguous leaf: the two are the same defect seen from opposite ends — one name answered by two tests, and one test answered by two names — so they are answered the same way. The refusal names the file and every line, in file order.

-

The key is the resolved test, not the spelling. ### f beside ### m::tests::f is two headings for one test, and grouping by the string would miss it precisely because qualification is the repair rm4s asks for; a heading qualified in one file and bare in another would have been the first way this came back. Resolution reuses the same leaf table the ambiguity rule uses, so a leaf that is genuinely ambiguous canonicalises to itself and is reported as ambiguous rather than as a duplicate — two different tests sharing a leaf are not two entries for one test, and conflating them would file the wrong repair. The check is over the whole directory rather than per file, because a heading keys a test name and a test name is unique workspace-wide once qualified.

-

What it still cannot see, and neither can any of the others: an entry that exists, names a live test, is the only one that does — and describes it backwards.

-

#3. The others

-

scripts/kb is the only writer of prolog-sessions/kb.pl; its own reasoning is in context/code/kb-tool.md. scripts/node-test.sh, scripts/kb-test.sh, scripts/pr-watch-test.sh and scripts/assemble-stamp-test.sh are the same shape as this file's subject: a guard, plus a suite that makes the guard refuse what it claims to refuse. Each is wired into .github/workflows/ci.yml in the same job as the thing it guards, because a check that only runs locally is a convention.

-

scripts/check-ci-graph.py is the workflow's own gate: pages-deploy must needs every other job in ci.yml. The rule "every check job" was written down (improvise-x7cq) and a job added during review of PR #83 was still ungated with nothing noticing, which is what moved it from a sentence to a check. It reads the job keys and the needs: list off indentation, tracking which job a needs: belongs to — taking the first one seen would read another job's list as the deploy's and pass. README.md therefore states the rule and deliberately does not list the jobs: a second copy with nothing holding it to the first is what went stale, twice.

-

#Why the Pages deploy fails on "Found 0 artifact(s)" (improvise-864a)

-

actions/deploy-pages@v5 intermittently fails a push to main with No artifacts named "github-pages" were found for this workflow run, while the upload step was green in that same run. The ticket framed this as concurrent merges racing on one Pages environment, and PR #46 proposed skipping the deploy unless the commit is the tip of main. Both the framing and the guard are wrong, and measurement says so.

-

What was measured. All 100 push-to-main CI runs from 2026-09-19T22:02Z to 2026-09-21T05:02Z, from the run, job, step and log APIs (job metadata and logs outlive artifacts, which expire in a day and are all 404 now — so this is reproducible where the ticket's own artifact-count table is not):

-
deployswith another run's SUCCESSFUL deploy completing inside this run's own upload→deploy window
12 artifact-not-found failures12 (100%)
56 successes2 (3.6%)
4 other failures0 — all four are the unrelated Create Pages site failed: Resource not accessible by integration, from before enablement: false (improvise-3gy)
-

The direction is settled, and it is neither of the two the ticket offered. The oldest run of the 17:47Z batch failed at 17:47:23Z — before the next deploy in the batch had started (17:47:31Z) and before any Pages deployment existed at all, since deploy-pages fetches the artifact metadata before it creates a deployment. So the failure cannot have been caused by this run's own deploy, by supersession, or by a concurrent deploy. The artifact was already gone. The reaper is another run's successful deployment.

-

It is scoped by artifact NAME, not by run. In run 35560726456 the build job finalized three artifacts seconds apart — harness-serve, wasm-bundles and github-pages, each with an id — and 2m21s later that run's own lookup reported Found 2 artifact(s) and no github-pages. Same run, same token, same call, same retention: the two siblings survived and only github-pages was missing. That one observation rules out the whole family of general explanations — service flakiness, eventual consistency, retention, a token scope, a v3/v4 backend mismatch — because any of them would have taken the siblings too.

-

Four things this kills. Queue depth is not the mechanism: the three deploys of the 17:47Z batch never queued, running 9s and 17s apart with each failure taking one second, so the pages group serialized nothing. Retention is not it either — all three shared retention-days: 1, and since the newest-finalized expires last, expiry can produce 0/1/1 or 0/0/1 but never the ticket's 0/1/0. Nothing in deploy-pages deletes an artifact (an upstream feature request asks for exactly that capability, which is only open because nothing does). And the action-version theory dies on the interleaving: successes and failures alternate minute by minute on identically-resolved action SHAs.

-

One clause of the ticket's own correction is falsified, and the conclusion still holds. The correction argued the tip-of-main guard would have skipped the one deploy that published "and let 52507a6 deploy and fail anyway" — which assumed 52507a6's artifact loss was independent of its neighbour. It was not: 5f871f7's deploy completed at 17:47:40Z, four seconds before 52507a6's began. Fewer successful deploys really is the right lever, so PR #46 aimed at the right quantity for the wrong reason. It should still not be shipped, because the association is not a mechanism and the mechanism is not deterministic: 5f871f7 had a foreign deploy land inside its own window too and survived. A guard measured at nothing, against a probabilistic effect, is doctrine(measure_the_obvious_fix_before_reporting_it_as_the_fix) exactly.

-

What the evidence does license. The risk lives entirely in the upload→deploy window — every victim had a foreign deploy land inside its own — so shortening that window is the one lever with evidence behind it, and consolidating the build already did some of that. Closing it properly means no two runs holding a github-pages artifact across a deployment, which is a real change to the job graph with real costs and is not attempted here. A retry is not a fix: the artifact is gone rather than transiently unlistable, so a retry cannot find it.

-
-
- - diff --git a/site/docs/code/tui.html b/site/docs/code/tui.html deleted file mode 100644 index b12071b8..00000000 --- a/site/docs/code/tui.html +++ /dev/null @@ -1,93 +0,0 @@ - - - - - -The TUI — improvise docs - - - -
- -
-

Source: context/code/tui.md

On this page

The terminal front end (src/)

-

The root crate is the ratatui binary: src/draw.rs (event loop and frame composition), src/ui/*.rs (the widgets), src/main.rs (clap). It is one of two front ends, and design-principles §5 is the rule it is judged by — it paints and wires, and every decision with a silently wrong answer is made in improvise-protocol so the browser cannot answer it differently.

-

Test narratives live in context/test-evidence/tui.md. Facts about GridCache, AxisFrame, Projection and the Command/Effect layer belong to context/code/protocol.md and context/code/command.md; this file cites them and does not restate them.

-
-

#src/ui/grid.rs — GridWidget

-

The grid is drawn from a GridCache (improvise-n10).

-

No &Model. Every value the grid paints — each cell's text, whether it is an aggregate, the column widths, the page bar and the totals strip — arrives already decided, on the cache. A widget that cannot reach the model cannot quietly answer a question differently from the way the browser client answers it, and that divergence is the whole thing projecting exists to prevent.

-

The frame comes off the cache too (improvise-anr9). GridCache carries an AxisFrame per axis — the header levels, the label tuples and the groups with their collapse state — so the multi-level headers, the repeat suppression, the group spans and the pieces below are all read rather than derived, and a browser painting the same frame paints the same thing. It used to be derived here from GridLayout + View::is_group_collapsed while the protocol carried GridLayout::row_label's slash-joined form — the rendering two_row_categories_produce_cross_product_labels asserts must not appear — so the browser drew Food/Alice.

-

Two things are still not on the cache and are passed separately, each a different kind of absence:

-
    -
  • layout resolves a records-mode column (record_col), and only that. It keys drill_state's pending edits, so it is session-local by design and correct to keep off the wire (gotcha 1).
  • -
  • drill_state is session-local residue (gotcha 30). Pending drill edits belong to whoever staged them and are deliberately not projected, so they shadow the cache rather than being on it.
  • -
-

The cache is expected to cover the whole grid — offsets zero, every row and every column — so a position in it is an absolute position and the widget's scrolling arithmetic is unchanged.

-

#Inside render_grid

-

GridWidget::cursor is this client's cursor in the active view (improvise-8uz), passed in rather than read off View, which no longer carries per-client state.

-

Everything about the frame is the cache's answer (improvise-anr9): how many header levels, what each level's label is, which group a position sits in and whether that group is collapsed. Nothing in render_grid reads GridLayout or View, so a browser folding the same AxisFrame draws the same header.

-

The bulk forms are built once per frame, and that is not a micro-optimisation. header_labels(offset), groups() and shares_ancestry(sel_col) each make one pass. The per-position accessors (item, label, shows_level, group_of, same_prefix) scan the frame from the start, so asking them inside the draw loops is quadratic in the grid and, at a deep scroll offset, grows with the offset. col_lines transposes header_labels to level-major because the column header is drawn one line per level while header_labels answers per column.

-

The underline on the column header marks columns sharing sel_col's ancestor group through each level: at the bottom level that is only sel_col, at higher levels it spans all the sub-columns.

-

Pending drill edits shadow the projected value. They are this session's uncommitted typing and by construction are not on the cache (gotcha 30).

-

Aggregate cells are drawn in italic to signal "drill to edit", and the fact is not re-derived here: the projection already named what each cell is, and CellId::Aggregate is exactly that fact (ADR 002), decided once by the side that holds the model. The widget used to walk none_cats × kind.is_regular() — a second hand-rolled copy of project::cell_id.

-

Whether there is a total row at all is the cache's answer. An empty totals strip means "no totals" (records mode, or an empty grid), which is why the widget does not read is_records_mode a second time.

-

The collapse glyph (indicator) is the one thing that is legitimately local: the fact — whether the group is collapsed — is on the frame; only its spelling is the terminal's (design-principles §5).

-
-

#src/draw.rs

-

#The key-event boundary

-

convert_key_event / convert_key_code / convert_modifiers are the only place crossterm's key types and the crate's own command::key types may both appear. Three rules:

-
    -
  • The event loop converts every incoming Event::Key regardless of KeyEventKind (Press/Repeat/Release), matching the pre-conversion behaviour, which never inspected the kind.
  • -
  • Codes the command layer does not model (media keys, Insert, …) become KeyCode::Null, which no keymap binds.
  • -
  • SHIFT/CONTROL/ALT survive; every other modifier bit (SUPER, HYPER, META) is dropped.
  • -
-

#Semantic-to-terminal colour, and the one that is wrong

-

This file holds the mapping design-principles §5 names as the shape everything else should imitate: the decision lives in the shared layer as a semantic enum, and only the last translation is local.

-
    -
  • axis_fg(AxisColor) -> Color — Axis::color is the single place each axis's accent is decided; this is the single place axis colouring is rendered (improvise-rml, gotcha 26). Before it, the tile bar and the category panel each carried their own copy of the colour table with nothing keeping them in agreement.
  • -
  • minibuf_fg(MinibufColor) -> Color — the same shape for the minibuffer.
  • -
  • src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color — the same shape again. improvise-protocol must not name a ratatui Color.
  • -
-

mode_style(&AppMode) -> Style stays here for the same reason (Style and Color are ratatui types and improvise-command, where AppMode lives, carries no rendering-framework dependency), and since improvise-zx8t it is exhaustive: the fourteen variants that take the default chrome are spelled out rather than swept up by a _ => wildcard, so adding an AppMode fails the build here until someone chooses its colour.

-

It is still not quite the same shape as axis_fg and its two siblings, and the difference is deliberate rather than pending. Those three map a semantic enum the shared layer owns; mode_style matches AppMode itself, because there is no ModeColor. improvise-zx8t offered making one, and it was declined here for two reasons: the colour is the only thing at stake, and the ticket says to decide it together with improvise-k099, which asks the same question of hint_text/label and is not in this unit. Exhaustiveness is what the repo map's "the compiler names every one you must fill in" promise needed; a semantic enum is a separate, larger answer and is still open.

-

The guard is the compiler, not a test. Nothing asserts the colours, and a test enumerating the variants would not fail when a fifteenth is added — only the missing match arm does. Verified by deleting the AppMode::Help arm: error[E0004]: non-exhaustive patterns: &AppMode::Help not covered.

-

#The minibuffer shares the bottom row with the status line

-

draw_bottom_bar used to render the minibuffer instead of draw_status, so a status message written while any text-entry mode was open was never drawn (improvise-mai9, and the stranding gotcha 21 describes). minibuffer_line composes both: the prompt and buffer on the left, any pending status_msg right-aligned. The alternatives and why they lost are in context/test-evidence/tui.md.

-

#The caches are built here, per frame

-

project::formulas_of, category_tree_of, view_list_of, tile_bar_of, grid_of and wizard_of are all called from draw/draw_content against the authoritative App. This is the temporary half of improvise-35e: when the TUI becomes a client it will hold each cache and fold the matching Projection into it, and none of the widgets change either way.

-

Two consequences today:

-
    -
  • The grid's viewport covers the whole grid, so a cache position is an absolute position (gotcha 44). Only the viewport narrows when the TUI becomes a client.
  • -
  • The wizard is built here rather than sent: it is session-local and has no Projection (improvise-764). It still goes through project, so the widget sees the same shape a browser wizard would.
  • -
-

draw renders overlays last so they appear on top. The which-key popup's armed prefix is stored as the key that opened it and resolved back to a sub-keymap by the keymap set (improvise-jc8, gotcha 13).

-
-

#src/ui/import_wizard_ui.rs — ImportWizardWidget

-

Drawn from a WizardCache (improvise-764).

-

No ImportWizard, no ImportPipeline, no Workbook. The widget used to hold the live wizard and read through it into improvise-io types and into the model — it called regular_category_names() and measure_item_names() on the built Workbook to write its one-line confirmation summary. Everything it draws now arrives already decided, from project::wizard_of.

-

The cache is not a Projection, and that is the ticket's answer to where wizard state lives: a half-open import dialogue belongs to the session that opened it, not to every connected client. WizardCache's own documentation carries the argument.

-

Smaller facts the drawing depends on: the root array path is already spelled (root) by the projection, so both ends say it the same way; the date components arrive in the order the wizard offers them, already paired with whether they are on, so the cursor index counts exactly those rows; and on Done the workbook is built but not yet installed, so the step shows what the user is about to accept, warnings included (improvise-907).

-

#set_bottom_line — one writer for the bottom row

-

That row has two writers — each step's key hint, and the wizard message that overrides it while present — and they write in that order to the same y. Buffer::set_string writes exactly as many cells as the string has and leaves the rest alone, so a message shorter than the hint it replaced left the hint's tail sitting beside it: something went wrongcel, the cel from Esc: cancel (improvise-q0vb).

-

Padding here rather than at each site is the point. The bug is not that one of the five writers forgot; it is that the row had five writers and no one of them owned clearing it.

-

The padding is measured in terminal cells, not characters. Buffer lays a string out by display width, so a char count over-pads a message carrying a wide character — enough to run past the panel and overwrite the border — and under-pads a combining mark, which is how a sliver of the hint would be left behind. src/ui/grid.rs measures the same way.

-

#truncate / prefix — bytes, deliberately

-

truncate cuts by byte length. prefix returns the longest prefix at most max bytes that ends on a char boundary, because every string truncate cuts can carry a field name, a formula or a message the user typed — and a multi-byte character straddling the cut used to panic the whole TUI on a slice at a non-boundary index. Walking down to the nearest boundary is the smallest change that cannot: for ASCII, which is what every fixture in this file holds, max is already a boundary and the result is byte-identical, so the characterization tests keep saying what they said.

-

It is bytes and not columns deliberately. Byte length is the measure truncate has always used, and changing it to display width would move where every line wraps — a different question from not crashing, and one that belongs with the hint-line padding it travels with. Bytes are never fewer than cells, so truncate cutting by bytes and set_bottom_line padding by cells cannot together overrun.

-

The blind spot, stated because the fixtures cannot state it: the eight exact-frame characterization fixtures in this file are entirely ASCII. They are therefore blind to every question about display width — a change to either measure is invisible to them, and the evidence for those has to be built directly (see set_bottom_line_clears_every_cell_it_covers_and_no_more).

-
-

#src/ui/help.rs

-

~600 lines of mostly-static content: five pages of styled (key, description) lines, a tab bar and a footer. improvise-ktv1 proposes moving that content into the shared layer so a browser help overlay is not a second copy of it. Until then it is TUI-only and help_page is already in ViewState, which is why gotcha 32 says the overlay needs no Projection.

-

#The panel widgets

-

src/ui/panel.rs holds the PanelContent trait and the generic Panel widget. The one non-obvious member is panel(): each impl declares its own identity, and the widget compares it against AppMode::panel() to decide whether to draw itself as active, instead of restating the mode match per panel.

-

category_panel.rs, formula_panel.rs, view_panel.rs and tile_bar.rs each draw one cache and nothing else. Two facts worth keeping:

-
    -
  • The category panel draws one flattened tree — the axis travels on the entry (improvise-edp), so there is no View in it.
  • -
  • The view panel's "which view is this client on" is read off ViewState, not the workbook: the active view is per-client and is deliberately absent from ViewListCache (improvise-8uz, gotcha 29).
  • -
-

tile_bar.rs scrolls by whole tiles: it finds the smallest start index at which the selected tile still fits in the available width, and draws ‹/› overflow indicators. That is the file's only piece of real arithmetic and it is local geometry, not a shared derivation.

-
-
- - diff --git a/site/docs/design-notes.html b/site/docs/design-notes.html deleted file mode 100644 index 7f2442d1..00000000 --- a/site/docs/design-notes.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - -Design notes — improvise docs - - - -
- -
-

Source: docs/design-notes.md

On this page

Design Notes

-

Staleness warning: This document captures conceptual framing from the original specification. It is not kept in sync with the code. For current architecture and types, see context/repo-map.md and context/design-principles.md.

-

#Product Vision

-

Traditional spreadsheets conflate data, formulas, and presentation into a single flat grid addressed by opaque cell references (A1, B7). This makes models fragile, hard to audit, and impossible to rearrange without rewriting formulas.

-

Improvise treats data as a multi-dimensional, semantically labeled structure -- separating data, computation, and views into independent layers. Formulas reference meaningful names, views can be rearranged instantly, and the same dataset can be explored from multiple perspectives simultaneously.

-

The application compiles to a single static binary and provides a rich TUI experience.

-

#Non-Goals (v1)

-
    -
  • Scripting/macro language beyond the formula system.
  • -
  • Collaborative/multi-user editing.
  • -
  • Live external data sources (databases, APIs).
  • -
  • Charts or graphical visualization.
  • -
  • Multi-level undo history.
  • -
-
-
- - diff --git a/site/docs/design-principles.html b/site/docs/design-principles.html deleted file mode 100644 index f1fea959..00000000 --- a/site/docs/design-principles.html +++ /dev/null @@ -1,193 +0,0 @@ - - - - - -Design principles — improvise docs - - - -
- -
-

Source: context/design-principles.md

On this page

Improvise Design Principles

-

#1. Functional-First Architecture

-

#Commands Are Pure, Effects Are Side-Effectful

-

Every user action flows through a two-phase pipeline:

-
    -
  1. Command (Cmd trait) — reads immutable context, returns a list of effects. The CmdContext is a read-only snapshot: model, layout, mode, cursor position. Commands never touch &mut App. All decision logic is pure.
  2. -
  3. Effect (Effect trait) — a small struct with an apply(&self, app: &mut App) method. Each effect is one discrete, debuggable state change. The app applies them in order.
  4. -
-

This separation means:

-
    -
  • Commands are testable without a terminal or an App instance.
  • -
  • Effects can be logged, replayed, or composed.
  • -
  • The only place App is mutated is inside Effect::apply.
  • -
-

#Prefer Transformations to Mutation

-

Where possible, build new values rather than mutating in place:

-
    -
  • CellKey::with(cat, item) returns a new key with an added/replaced coordinate.
  • -
  • CellKey::without(cat) returns a new key with a coordinate removed.
  • -
  • Viewport positioning is computed as a pure function (viewport_effects) that returns a Vec<Effect>, not a method that pokes at scroll offsets directly.
  • -
-

#Compose Small Pieces

-

Commands compose via Binding::Sequence — a keymap entry can chain multiple commands, each contributing effects independently. The o key (add row + begin editing) is two commands composed at the binding level, not a monolithic handler.

-

#Decompose rather than early return

-

Early Returns usually are a signal of mixed responsibilities: if an early return would clarify a function, consider how the function could be decomposed for the same effect without the early return.

-
-

#2. Polymorphism Over Conditionals

-

#Dispatch Through Traits and Registries, Not Match Blocks

-
    -
  • Commands: 40+ types each implement Cmd, organized by concern across submodules in command/cmd/ (navigation, cell, commit, grid, mode, panel, search, text_buffer, tile, effect_cmds). A CmdRegistry maps names to constructor closures. Dispatching a key press looks up the binding, resolves the command name through the registry, and calls execute. No central match command_name { ... } block.
  • -
  • Effects: 50+ types each implement Effect. Collected into a Vec<Box<dyn Effect>> and applied in order. No match effect_kind { ... }.
  • -
  • Keymaps: Each mode has its own Keymap (a HashMap<KeyPattern, Binding>). Mode dispatch is one table lookup, not a nested match (mode, key).
  • -
-

#Use Enums to Make Invalid States Unrepresentable

-
    -
  • BinOp is an enum (Add | Sub | Mul | ...), not a string. Invalid operators are caught at parse time, not silently ignored at eval time.
  • -
  • Axis is Row | Column | Page | None. A category is on exactly one axis. Cycling is a four-state rotation — no boolean flags, no "row_or_column" ambiguity.
  • -
  • Binding is Cmd | Prefix | Sequence. The keymap lookup returns one of these three shapes; dispatch pattern-matches exhaustively.
  • -
  • CategoryKind is Regular | VirtualIndex | VirtualDim | VirtualMeasure | Label. Business rules (e.g., the 12-category limit counts only Regular) are enforced by matching on the enum, not by checking name prefixes. Virtual categories (_Index, _Dim, _Measure) always exist: _Index and _Dim support drill-down/records mode; _Measure holds numeric data fields and formula targets (added automatically by add_formula). Use Model::regular_category_names() when selecting a default category for prompts or other user-visible choices.
  • -
-

#When You Add a Variant, the Compiler Finds Every Call Site

-

Prefer exhaustive match over if let or _ => 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.

-
-

#3. Correctness by Construction

-

#Canonical Forms Prevent Equivalence Bugs

-

CellKey::new() sorts coordinates by category name. Two keys that name the same intersection but in different order are identical after construction. Equality, hashing, and storage all work correctly without callers needing to remember to sort. Property tests verify this invariant.

-

#Smart Constructors Enforce Invariants

-
    -
  • CellKey::new() is the only way to build a key — it always sorts.
  • -
  • Category::add_item() deduplicates by name and auto-assigns IDs via a private counter. External code cannot fabricate an ItemId.
  • -
  • Model::add_category() checks the 12-category limit before insertion.
  • -
  • Formula::new() takes all required fields; there is no default/empty formula to accidentally leave half-initialized.
  • -
-

#Type-Safe Identifiers

-

CategoryId and ItemId are typed aliases. While they are usize underneath, using named types signals intent and prevents accidentally passing an item count where an item ID is expected.

-

#Symbol Interning for Data Integrity

-

DataStore interns category and item names into Symbol values (small copyable handles). This means:

-
    -
  • String comparison is integer comparison — fast and allocation-free.
  • -
  • A secondary index maps (Symbol, Symbol) pairs to cell sets, enabling O(1) lookups for aggregation queries.
  • -
  • Symbols can only be created through the SymbolTable, so misspelled names produce a distinct symbol rather than silently matching a wrong cell.
  • -
-

#Parse-Time Validation

-

Formulas are parsed into a typed AST (Expr enum) at entry time. If the syntax is invalid, the user gets an error immediately. The evaluator only sees well-formed trees — it does not need to handle malformed input.

-

#Grammar-Defined File Format

-

The .improv file format is defined by a PEG grammar (persistence/improv.pest) and parsed by pest. The grammar is the single source of truth — the parser is a tree-walker over the grammar's parse tree, not an ad-hoc line scanner. This means:

-
    -
  • Adding a new format feature means updating the grammar first, then the walker.
  • -
  • The grammar can be read as a specification independent of the Rust code.
  • -
  • A grammar-walking test generator reads the grammar AST at test time (via pest_meta) and produces random valid files, ensuring the parser accepts everything the grammar describes.
  • -
-

#CL-Style Pipe Quoting for Names

-

Names in the .improv format use CL-style |...| pipe quoting. A name is bare if it matches [A-Za-z_][A-Za-z0-9_-]*; everything else must be pipe-quoted. Escapes inside pipes: \| (literal pipe), \\ (backslash), \n (newline). This convention is shared between the .improv persistence format and the formula parser's identifier syntax.

-

#Formula Identifiers and Quoting

-

Bare identifiers are (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_" | "-")* — no spaces, the same shape improv.pest's bare_name has. A multi-word name such as Total Revenue must be pipe-quoted.

-

Keywords need a word boundary after them, not before. IS, WHERE and the aggregate names are spelled ^"…" in formula.pest, and is_expr/where_clause/ inline_where are compound-atomic with sp+ so that Region ISLAND and SUM(Rev WHEREAS x = y) are parse errors rather than silently-wrong filters. A SUM/IF without parens falls through to ref_expr, which is why a bare keyword still names an item; the writer quotes it anyway (quote::quote_name).

-

Pipe-quoted identifiers (|...|) allow any characters — including spaces, keywords, and operators — inside the delimiters. Use pipes when a category or item name collides with a keyword or contains special characters:

-
|WHERE|             — category named "WHERE"
-|Revenue (USD)|     — name with parens
-|Cost + Tax|        — name with operator chars
-SUM(|Net Revenue| WHERE |Region Name| = |East Coast|)
-

identifier is pipe_quoted | bare_ident, so a pipe-quoted name works everywhere a bare one does: expressions, aggregate arguments, and a WHERE clause's category name. A double-quoted string is a filter_value only — the right-hand side of a WHERE or an IS, never an expression.

-
-

#4. Separation of Concerns

-

#Four Layers

-
LayerDirectoryResponsibility
Modelsrc/model/Categories, items, groups, cell data, formulas. Pure data, no rendering.
Viewsrc/view/Axis assignments, page selection, hidden items, layout computation. Derived from model.
Command / Effectsrc/command/, src/ui/effect.rsIntent (commands) and state mutation (effects). Bridges user input to model changes.
Renderingsrc/draw.rs, src/ui/Terminal drawing. Reads model + view, writes pixels. No mutation.
-

#Formulas Are Data, Not Code

-

A formula is a serializable struct: raw text, target name, category, AST, optional filter. It is stored in the model alongside regular data. The evaluator walks the AST at read time. Formulas never become closures or runtime-generated code.

-

#Formula Evaluation Is Fixed-Point

-

recompute_formulas(none_cats) iterates formula evaluation until values stabilize. Each pass evaluates all formula cells using the current cache (for formula-derived values) and raw data aggregation (for data values). This avoids recursive evaluation through evaluate_aggregated and naturally handles chained formulas (Margin = Profit / Revenue where Profit = Revenue - Cost). Circular references converge to CellValue::Error("circular") after MAX_EVAL_DEPTH iterations.

-

#Display Rounding Is View-Only

-

Number formatting (format_f64) rounds for display. Formula evaluation always operates on full f64 precision. The rounding function is only called in rendering paths — never in eval_formula or aggregation.

-

#Drill State Isolates Edits

-

When editing aggregated (drill-down) cells, a DrillState snapshot freezes the current cell set. Pending edits accumulate in a staging map. On commit, ApplyAndClearDrill writes them all atomically. On cancel, the snapshot is discarded. No partial writes reach the model.

-
-

#5. A Front End Displays State; It Does Not Reimplement Behaviour

-

The goal is to minimise the amount of new code a new UI framework needs. Logic is centralised in a redux-like store, so a second or third front end is mostly a function from state to pixels plus a wire for events — not a second implementation of how the application behaves.

-

The pieces already exist and are worth naming as what they are:

-
reduxhere
actionCommand / ModelCommand (improvise-protocol)
reducerreduce_view, and WidgetCache::apply per widget
storeViewState + the per-widget caches
dispatchSessions::apply_checked — the one door (gotcha 36)
-

So the question to ask of any code in a front end is: would a third front end have to write this again? If yes, it is behaviour and it belongs in the shared layer. Three things make this concrete rather than aspirational:

-

The measure is lines of framework-specific code, and it should trend to "paint and wire". web/app.js is the current benchmark: a few hundred lines that know no protocol, reaching the application through a handful of string calls. A front end that needs to understand CellKey coordinates, or which category a row label belongs to, or what a keypress means, is one that has been handed a job the store should have done.

-

A derivation done twice is the defect, even when both copies are correct today. Two front ends deriving the same fact will diverge, and the divergence is invisible until someone compares them side by side. improvise-edp is the worked example — the category tree was flattened twice, two different ways, and the fix put viewmodel::flatten_cat_tree where both ends call it. Gotcha 44 was the same defect and is now closed the same way (improvise-anr9): the grid's frame — header levels, label tuples, group runs and their collapse state — is improvise_protocol::AxisFrame, and both src/ui/grid.rs and improvise-wasm-client/src/render.rs ask it rather than deriving one. Note what the second cure cost that the first did not: a Vec<String> of labels had to change type, and a shared derivation could not be added beside the old one — row_levels next to row_labels would have been two answers to "what are the labels", which is the disease and not the cure.

-

Sharing the table beats testing two copies of it. Where a front end must turn input into actions, share the mapping rather than mirroring it: two hand-kept copies held in step by a test are one definition waiting to be made (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)). That is why the keymap is shared rather than reimplemented per front end, and why doctrine(mirror_the_cmd_line_for_line) is a rule about resolvers — the narrow, unavoidable residue — and never a licence to restate a table.

-

What legitimately stays per-framework. Painting, event wiring, and the mapping from a semantic value to a framework type: AxisColor → ratatui::Color in draw.rs::axis_fg, MinibufColor → minibuf_fg. That mapping is the shape to imitate — the decision is in the shared layer as a semantic enum, and only the last translation is local. Anything larger than a lookup table is a candidate for the store.

-
-

#6. Guidelines for New Code

-
    -
  • Add a command, not a special case. If you need new behavior on a keypress, implement Cmd, register it, and bind it in the keymap. Do not add an if key == 'x' branch inside handle_key.
  • -
  • Return effects, do not mutate. Your command's execute receives &CmdContext (immutable). Produce a Vec<Box<dyn Effect>>. If you need a new kind of state change, create a new Effect struct.
  • -
  • Use the type system. If a value can only be one of N things, make it an enum. If an invariant must hold, enforce it in the constructor. If a field is optional, use Option — do not use sentinel values.
  • -
  • Test the logic, not the wiring. Commands are pure functions of context; test them by building a CmdContext and asserting on the returned effects. You do not need a terminal.
  • -
  • Keep Option/Result/Box at the boundaries. Core logic should work with concrete values. Wrap in Option at the edges (parsing, lookup, I/O) and unwrap early. Do not thread Option through deep call chains.
  • -
  • Reasoning lives in context/, not in comments. A comment run is at most two consecutive lines, /// and //! included, enforced by scripts/check-comment-runs.py. Where a reader would otherwise re-derive a rule wrongly, name the context/ file that carries the argument — and only there, because a pointer on every function is noise. The prose lives in context/code/<area>.md; the rule exists because the arguments in this codebase are its most expensive artefact and a comment is the one place nothing checks them against the code (user, 2026-09-20; improvise-9c8c). A ``` fence in a doc comment is a doctest — a running test, not prose — and is exempt.
  • -
  • And an argument must not live only in a commit message. A message is write-once and unauditable: it cannot be corrected without rewriting merged history, and no check in this repo can read one — the ID audit greps context, README.md, AGENTS.md, CLAUDE.md, crates, src, tests, examples, web, harness and docs, and a commit message is in none of them. So a reason recorded only there is a reason nothing can ever find wrong. When one turns out to be wrong, correct context/ and say in context/ that the message is superseded; that is the fix rather than a compromise. PR #51's 7d25762 is the worked example, corrected by #56.
  • -
-
-

#7. Testing

-

#Coverage and ambition

-

Aim for ~80% line and branch coverage on logic code. This is a quality floor — go higher where the code is tricky or load-bearing, but don't pad coverage by testing trivial getters or chasing 100% on rendering widgets. The test suite should remain fast, and the rule is two numbers because the ambiguity between them is what let the old single "under 2 seconds" drift: no single test binary may exceed 2 seconds, and the workspace wall clock is the sum of them. Excludes compilation, and excludes harness/, which is Playwright and takes minutes in its own CI job. Slow tests erode the habit of running them.

-

The threshold is a tripwire, and the seconds are not portable. The same commit measured improvise-io's lib binary at 1.4s on one machine and 3.7–5.2s on another (improvise-8ec3), so a PR body citing "longest single binary 1.47s" is checkable and false for the reader who checks it, which is worse than no claim at all. Three consequences:

-
    -
  • Re-measure; never re-assert. cargo test --workspace prints a finished in per binary. Read your own run.
  • -
  • If you cite a figure, name the machine you took it on. A number with no machine is a property of the suite, which it is not.
  • -
  • The portable signal is the ratio, not the seconds. One binary an order of magnitude above the run's median is the thing to look at — not above second place, which it need not be: on one machine improvise-io is 2.2× the runner-up and ~15× the median. It is the same binary on all three machines it has been measured on: improvise-io's lib, which was proptest-dominated by the grammar-walking .improv generator and the round-trip properties over it.
  • -
-

improvise-8shu is closed, and its diagnosis was wrong — which is the more useful half. The ticket blamed an unbounded grammar walk and asked for a constrained strategy. Instrumenting the two halves separately: load_grammar() ×1000 took 2.50s against a 15.7ms walk, so the walk was 0.6% of the cost and improv_file() was re-parsing improv.pest once per generated case. Caching it in a OnceLock took improvise-io's lib from 1.42s to 0.21s with the strategy's input space unchanged; constraining it would have moved 15.7ms and shipped as "the fix". Measured at f152d10 on the orchestrator's machine: 1445 tests over 34 binaries, longest single binary 0.6–1.0s across two runs — the spread on one machine in one sitting being its own argument for reading your own run. improvise-io is no longer the outlier. Lowering the case count below 256 is still not on the table (see below).

-

#Demonstrate bugs before fixing them

-

Write a test that fails on the current code before writing the fix. Prefer a small unit test targeting the broken function over an end-to-end test. After the fix, the test stays as a regression guard.

-

Record the bug in context/test-evidence/, keyed by test name — what broke, what fault was injected, and why the test can fail. It does not go in the test's doc-comment: comment runs are capped at two lines (see below), and the evidence is longer than that in every case worth having. The cost of the split is that a renamed test orphans its entry. scripts/check-comment-runs.py --check-evidence catches that, in two directions. Every ###/#### heading shaped like a test name must name a test that runs — the names are diffed against cargo test --workspace -- --list minus its --ignored twin, never scraped out of the source, because text that looks like a test and a test are indistinguishable to a scanner (improvise-rm4s). And a test whose doc comment cites context/test-evidence/ must have an entry there, or the pointer reads as a promise the explanation exists.

-

Neither direction is "every test has evidence", deliberately: most tests have none and should not be forced to have one. The reasoning and the residual holes are in context/code/tooling.md.

-

#A check that looks like the answer, answering a neighbouring question

-

The characteristic defect in this repo is not a wrong line; it is a check that is adjacent to the thing it names. Three instances landed in one campaign (PRs #59/#60), in three different layers, which is why it is worth stating as a shape rather than three gotchas:

-
    -
  • A vacuous assertion. set_panel_open_and_cursor asserted category_panel_open was true after an effect set it true — once the launch default supplied true, the effect could be replaced by a no-op and 1391 tests passed. It answered is the flag true where it meant did the effect set it.
  • -
  • A vacuous repair of it. The first fix folded [open:false, open:true] and asserted the final state — which a reducer that folds nothing also satisfies. It answered does the text mention the other direction where it meant does the writer move the flag both ways. Reading the diff is what failed, and it failed because the vacuous version contained open: false.
  • -
  • A clean auto-merge. git merge-tree reporting no conflict answers can git combine these texts, where the question is does the combination do what both sides meant. Two PRs edited the same four files with no conflict and neither side CI-tested together.
  • -
-

Each has the same remedy shape: name the question you actually mean, then find an input that separates it from its neighbour.

-
    -
  • For an assertion: assert the direction the default cannot supply. Closing the panel discriminates; opening it proves nothing.
  • -
  • For a repair: inject a one-directional fault (if open { set true } — can raise, never lower). A vacuous repair cannot see it, because a test that only asserts the true direction is satisfied by a writer that only ever writes true.
  • -
  • For a merge: materialise it and run the gates, and check that a count which should MOVE actually moved. Playwright going 16 → 17 is what showed the other side survived; "no conflict" and an unchanged total would both have been consistent with losing it.
  • -
-

This generalises doctrine(fold_tests_must_test_order_not_the_axiom) — asserting fold(prefix)+fold(rest) == fold(whole) is the definition of a left fold, so it answers a neighbouring question too. What the campaign adds is that the pattern recurs in test repairs and in merge hygiene, not only in production code and not only in fold tests.

-

#E2E pins the boundaries; unit tests work between them

-

E2E and integration tests are rare and pin boundary contracts. Unit tests assume those contracts and test between the boundaries (user, 2026-09-20).

-

So the question to ask of a slow test is never "what feature does this cover" — it is "which boundary is this, and what is the contract at it?" The answer changes what you write. Four browser widgets had never worked — the tile bar, the view panel, the formula panel, and the category sidebar ignoring category_panel_open — and all four were one boundary failure: a RenderModel field with no element painting it. Four per-widget Playwright specs cost four slow tests and would still have missed the fifth; a single contract assertion that every field the render model exposes is painted catches all four and the next one. harness/tests/opfs.spec.js is the cleanest instance of the right shape already in the tree: it runs the storage contract, not a feature.

-

Three corollaries, in the order they bite:

-
    -
  • A unit test may assume the contract rather than re-establishing it. That assumption is what makes it cheap, and it is only sound because something slow pins the contract — so deleting the contract test silently invalidates every unit test that leaned on it.
  • -
  • Never weaken a contract to make it cheap. If the honest contract needs a real browser, a real Worker or real OPFS, it stays slow. Moving it to a faster harness that cannot see the thing it names is how a suite goes green over a broken product.
  • -
  • Each slow test must say which contract it pins. The difference between a contract test and a feature test is invisible from the code, so it has to be written down or the next person adds the fifth per-widget spec.
  • -
-

#Use property tests judiciously

-

Property tests (proptest) are for invariants that must hold across all inputs — not a replacement for example-based tests. Good candidates:

-
    -
  • Structural invariants: CellKey is always sorted, each category lives on exactly one axis, toggle-collapse is involutive, hide/show roundtrips.
  • -
  • Serialization roundtrips: save/load identity.
  • -
  • Determinism: evaluate returns the same result for the same inputs.
  • -
-

Keep case counts at the default (256). Don't crank them to thousands — if a property needs more cases to feel safe, constrain the input space with better strategies rather than brute-forcing. Property tests that take hundreds of milliseconds each are a sign something is wrong.

-

#What to test

-
    -
  • Model, formula, view: the core logic. Unit tests for each operation and edge case. Property tests for invariants. These are the highest-value tests.
  • -
  • Commands: build a CmdContext, call execute, assert on the returned effects. Pure functions — no terminal needed. Tests are colocated in each command submodule (command/cmd/<module>.rs → mod tests), with shared test helpers in command/cmd/mod.rs::test_helpers.
  • -
  • Persistence: round-trip tests (save → load → save produces identical output) plus grammar-driven property tests. The generator walks the pest grammar AST to produce random valid files; proptests verify parse(generate()) succeeds and parse(format(parse(generate()))) is stable. Cover groups, formulas, views, hidden items, pipe quoting edges.
  • -
  • Format: boundary cases for comma placement, rounding, negative numbers.
  • -
  • Import: field classification heuristics, CSV quoting, multi-file merge.
  • -
-

#What not to test

-
    -
  • Ratatui Widget::render implementations — pure drawing code that changes often. Test the data they consume (layout, cat_tree, format) instead.
  • -
  • Trivial data definitions (ast.rs, axis.rs).
  • -
  • Module re-export files.
  • -
-

#Test the property, not the implementation

-

A test like "calling set_axis(cat, Row) sets the internal map entry to Row" is brittle — it mirrors the implementation and breaks if the storage changes. Instead test the observable contract: "after set_axis(cat, Row), axis_of(cat) returns Row and categories_on(Row) includes cat." This style survives refactoring and catches real bugs.

-
-
- - diff --git a/site/docs/doc-site.html b/site/docs/doc-site.html deleted file mode 100644 index 75dd3fcd..00000000 --- a/site/docs/doc-site.html +++ /dev/null @@ -1,69 +0,0 @@ - - - - - -About this site — improvise docs - - - -
- -
-

Source: context/code/doc-site.md

On this page

The documentation site — the arguments

-

scripts/build-doc-site.mjs turns the prose in context/ into the static site served at /docs/ on GitHub Pages. This is the reasoning behind what it publishes, what it deliberately does not, and why it is a hundred-odd lines of node rather than a static site generator (improvise-djoo).

-
-

#1. What is published, and what is not

-

The manifest is the SECTIONS array at the top of the script, and a document is published only by being named there. That is the point: a glob over context/** would publish the next working file somebody drops in, and the decision about what a stranger should read is not one to make by accident.

-

Published, in four sections:

-
SectionContentsWhy
Start heredocs/design-notes.md, context/design-principles.md, context/repo-map.mdWhat the project is for, the doctrine, and the single best explanation of how it is built.
Decision recordscontext/adr/*.mdAn ADR is written for an outside reader by construction — it states the options that were rejected, which is the part that is worth reading and the part that is nowhere else.
The arguments, by areacontext/code/*.mdThis is the reasoning that used to live in code comments and was moved out under the two-line cap (design-principles §6). It is the most valuable prose in the repository and it is addressed to exactly the reader a documentation site has.
Test evidencecontext/test-evidence/*.md501 entries, one per test whose narrative is longer than its code.
-

Not published:

-
    -
  • context/plan.md is a prompt: it opens "You are working in the improvise repository… Your job is to prepare this repository for a public Show HN launch", and it instructs a reader to audit a context/SPEC.md that no longer exists. It is an instruction to an agent about a launch, not a description of the program, and publishing a launch plan on the site the launch points at inverts the two.
  • -
  • context/cloud-agents.md is operational notes for agents running in a cloud session — tracker hydration, bd pitfalls, what the sandbox blocks. It is true and it is useful and it is addressed to nobody who visits a website.
  • -
  • context/code/pr-review.md is how an agent reads a PR's reviews — where a ChatGPT Codex review appears, how to tell it from this repo's own agents' comments, and why absence is not approval. It is about this repository's process, not about the program, which is the cloud-agents.md line and not the tooling.md one: tooling.md explains checkers a reader of the code will meet, and nobody visiting a website is about to read a PR thread.
  • -
-

None of the three is referenced by any published document except as a row in repo-map.md's file inventory, which is inside a code fence, so leaving them out creates no dangling link. That was checked rather than assumed.

-

And "not published" is now derived rather than assumed (improvise-res9). The generator walks context/**/*.md plus docs/design-notes.md, subtracts the manifest, and fails unless the remainder is exactly the OMITTED map in the script — which carries the reason beside the name, so this section and the code cannot drift into disagreeing. An allowlist and not a count, because an omission is a decision that should have to be argued in code; and the map is checked in both directions, because an entry naming a file nobody can find asserts a reason for nothing, which is the failure a dead ticket id is.

-

The check was latent when it was filed and live by the time it was written: context/code/kb-tool.md had been added after this table was drafted and was publishing nothing, with no argument for its absence anywhere. The table above says context/code/*.md is published; the manifest disagreed and nothing could see it. That is the whole failure mode in one file.

-

Test evidence is the one judgement call worth stating. The argument for leaving it out is that 501 entries about individual tests are noise to a reader who wanted to know what a pivot table is. The argument for keeping it is stronger: every one of the seven context/code/*.md documents points at its evidence file by name, so omitting it would break the published prose in exactly the way this site is otherwise careful not to; and the evidence file is the most distinctive artefact the repository has — a written record of the injected fault behind each load-bearing test is not a thing most projects can show. It is therefore published as its own section, last, described as a contributor reference rather than a tour, so nobody wanders into it expecting a narrative.

-

#2. Ticket ids resolve to a page, never to a URL that does not exist

-

The prose cites bead ids roughly 190 times — this paragraph deliberately spells none, because a document that gives one as an example makes itself a source of audit noise, and a placeholder is worse than a real id: it is ID-shaped and resolves to nothing. On a public site those citations are meaningless strings, and the obvious repair — link them to an issue tracker — is unavailable: beads keeps its issues in a Dolt database on a git ref, and there is no public issue URL to point at. Inventing one would produce 190 links that 404, which is worse than plain text because a link is a promise.

-

So the ids resolve inward. tickets.html is generated from .beads/issues.jsonl — already committed, already public — and carries id, status and title, nothing else; descriptions are internal working notes and are not the site's business. Each citation in the prose becomes a link to that page's anchor, with the title in a title= attribute so a hover answers the question without a navigation.

-

issues.jsonl is a passive export and lags the database, which is not a defect to work around but a fact to render honestly: an id the export does not carry is emitted as plain text with a dotted underline and no link, and the ticket page lists it as "not exported". The site therefore never links to an anchor it did not generate, whatever state the export is in. A generator that instead linked every id it saw would produce a page that is right today and broken the first time somebody cites a fresh ticket.

-

The page publishes every cited id, open bugs and their titles included, and that is a decision rather than a default (user ruling). The data is already public in .beads/issues.jsonl in a repository anyone can clone; the page only makes it legible. An honest open-bug list is a normal thing for an open-source project to have, and filtering one while the page's own lede links the raw JSONL would be theatre. Note the second-order reason to keep it unfiltered: a citation whose row was suppressed would be a link into a page that does not answer it, which is the failure this whole section exists to avoid.

-

Titles are tracker data and are escaped as attribute values. Five shipped bead titles contain a double quote; interpolated into title="…" by an esc that handled only &, < and >, they broke out of the attribute and the browser parsed the remains as three junk attributes. esc now escapes " and ' as well. Today's corpus being friendly is not a reason to interpolate untrusted-shaped text unescaped — the tracker is written by whoever files a bug.

- -

A code span whose entire content is a repo path becomes a link — to the generated page when the path is one of the published documents, to GitHub otherwise. Two constraints make that safe. The whole span must be the path, so a sentence that merely mentions a directory is untouched; and the path must exist on disk at generation time, so a citation that has gone stale degrades to plain text rather than to a 404. The GitHub ref is the cache-bust token web/assemble.sh already receives, used only when it is a 40-hex sha and falling back to main otherwise — a local dev build must not emit blob/dev/….

- -

The generator collects every anchor it emits and every internal href it writes, resolves them against each other, and exits non-zero if any href names a page it did not generate or a fragment that page does not carry. web/assemble.sh runs under set -eu, so a broken cross-reference fails the assembly rather than shipping.

-

This is not ceremony. It caught two real defects on its first two runs: a fence-close regex that compared a three-character marker against a one-character string, which swallowed every document from its first code block onward; and a list parser that ended the gotcha list at the first blank line, so 47 of the 48 gotcha anchors were never emitted while the "jump to gotcha N" strip linked to all of them. Both were invisible in a rendered page that otherwise looked fine.

-

It is proved by injection rather than asserted: renaming the emitted anchor ids makes it report 48 missing anchors, and pointing the ticket links at a page name that is never generated makes it report 763 missing pages — two different diagnoses, because "the page is absent" and "the page is there but the anchor is not" call for different repairs.

-

Links that leave the docs/ subtree — ../index.html, ../app/ — name files this script does not generate, so they are resolved against the filesystem instead. That is the whole reason assemble.sh runs the generator last: its siblings have to exist before they can be checked. An earlier version skipped those 48 links while still counting them as checked, behind a comment promising they were "checked below" when there was no below.

-

That ordering is itself checked now (improvise-res9). The generator reads web/assemble.sh and refuses to run unless its own invocation is that script's last command. PR #63 appended a loop and it happened to land above the generator; one landing below would have made the outward-link check pass against a half-built tree, silently and in the one direction this section says must not go unnoticed.

-

And the link check is not sufficient, which is the more important lesson. It answers one question — does every href resolve — and a defect outside that question is invisible to it however carefully it is run. The worked example is a nested <a>: a TOC entry wrapping a heading whose rendered text already contained a link produced <a href="#anchor"><a href="github…">…</a></a>, and the HTML parser closes the outer anchor early, so the entry became an empty unclickable stub and clicking it navigated to GitHub. Both hrefs resolved perfectly. It shipped past the link check, past the whole-site link check, and past a screenshot pass, and was caught by a reviewer clicking a link. The repair is therefore two things, not one: the TOC keeps a tag-free variant of each heading, and a separate check asserts no <a> appears inside another <a> — proved by reverting the first half and watching the second report 66.

-

The general form: when a check misses a defect, add the check that could have seen it rather than only fixing the defect, and ask what question the existing check was answering.

-

#5. Why a node script and not a generator

-

The landing page is hand-written HTML with no build step and one pinned CDN script; the same posture applies here. A static site generator would add a dependency tree, a lockfile and a toolchain to a repository whose site is currently four files, in exchange for features this corpus does not use — the docs contain no front matter, no shortcodes and, as it happens, not a single markdown link. Every cross-reference in them is prose naming a path or an id, which is precisely why §2 and §3 are the interesting parts and Markdown rendering is the boring part.

-

So the renderer is a deliberately small subset covering exactly what the corpus contains: ATX headings, fenced code, pipe tables, blockquotes, ordered and unordered lists with indented continuations, thematic breaks, and inline code, strong, emphasis and links. Anything it does not recognise becomes paragraph text, so no input line is ever dropped — the failure mode is an ugly paragraph, never a missing one.

-

Two details in it are load-bearing and easy to undo. Code spans are replaced by placeholders before emphasis is matched, because this corpus routinely opens ** on one side of a code span and closes it on the other; matching emphasis per segment silently drops that bold, which is a fidelity loss with no visible symptom. And strong is matched before emphasis, with a body that may contain a single * pair, because [^*]+ cannot match **a *b* c** and the leftover * then pairs across the bold — which is how a single sentence in gotcha 33 came out as three italic paragraphs.

-

A third, added later and for the same reason: a table cell boundary is an unescaped pipe outside a code span, so the row is walked rather than split("|"). This corpus documents a file format whose own quoting syntax is |Time Period|, and a sentence containing one inside a table cell would have been shredded into extra columns — a row that still parses, so no column count could detect it. It was latent when fixed, which is the point: the trigger is one ordinary sentence away in a document whose subject is pipe quoting.

-

One deliberate divergence from GFM inside that rule: a \| inside a code span in a table cell keeps its backslash, where GFM would unescape it to a bare pipe. Splitting is suppressed inside code spans, so the escape is never needed there and never consumed. The corpus contains no instance; it is recorded as a decision rather than left to be rediscovered as a bug.

-

#6. Navigation

-

repo-map.md is one long document whose gotcha list is numbered 1 to 48, and splitting it per gotcha was rejected: the gotchas cross-reference each other constantly ("see gotcha 35", "for the reason gotcha 39 watches…"), and a reader following those wants a scroll, not 48 page loads. It stays one page, with an id on every list item and a compact strip of numbered links under the heading. The ids come from the list item's own number, not from a counter, so an inserted gotcha renumbers the anchors exactly as it renumbers the prose that cites them.

-

The strip's contents are derived from the anchors actually emitted, never from a count. A hardcoded 48 failed in only one direction: deleting a gotcha left a link to a missing anchor, which the link check catches, while adding one produced an unlinked gotcha and a clean build. The list only ever grows, so the hardcoded form was silent in precisely the direction it would be used — the same shape as an exclusion list that over-matches, failing in the direction that looks like success.

-

Long unbreakable headings need overflow-wrap. The test-evidence pages are hundreds of snake_case test names as headings; at phone width seven of them overflowed the viewport by up to 92%. The diagnosis is worth keeping because the first guess was wrong: getBoundingClientRect reported every element inside the viewport, because the text overflows its box without widening it. Only scrollWidth per element names the culprit. Measure the property that actually overflowed rather than the one that usually does.

-

#7. The phone nav is a checkbox, and that is not arbitrary

-

At phone width the sidebar's 25 links stacked above the content, so every page opened on roughly 1500px of nav. The wall was not inherited: all of this CSS is new, so it shipped for the first time with the site, which is the test for whether a defect is pre-existing and the one most easily got wrong in the direction of declining work you introduced.

-

<details> is the obvious control and it is wrong here, for a reason no spec-level reading predicts. The sidebar layout has to force the disclosure open at wide widths, and a closed <details> hides its content through ::details-content — which display cannot override. Measured in Chromium, the desktop sidebar came out collapsed to a single link with its toggle also hidden: unusable, and in the half nobody would think to re-check after a phone-only change. A hidden checkbox plus a <label> puts the hiding in our media query, so forcing it open is ordinary cascade. Anyone reading the markup will see a checkbox where <details> obviously belongs; this paragraph is why it must not be tidied into one.

-

And the first probe of the fix could not see the difference. Counting nav links whose getBoundingClientRect().height > 0 reported 26 visible in both the collapsed and the expanded state, because Chrome reports a height for content-visibility-hidden children — so it would have passed the broken <details> build. checkVisibility({contentVisibilityAuto: true, visibilityProperty: true}) discriminates.

-

That is the same instrument failing twice: §6's overflow fix also measured clean by getBoundingClientRect while the page still scrolled. A measurement that cannot distinguish the two states is not a measurement — the repo's signature defect, a test that passes while the thing it names is broken, arriving through the instrument rather than through the assertion. Before trusting a browser measurement, injure the thing being measured and check the number moves.

-

And the fix's own probe had the same shape one level up. Counting visible nav links answers "does operating the toggle reveal the links" — correct and complete for a mouse — and is silent on whether any non-mouse route exists. The first version of the checkbox carried hidden, which is display:none, which removes it from the tab order and from the accessibility tree: the label is not focusable on its own, so at ≤900px the navigation could be clicked and reached no other way, and the accessibility snapshot contained neither the checkbox nor any of the 25 links. A visibility count cannot see that, however sharp the visibility instrument. The toggle is therefore visually hidden but focusable at narrow widths and display:none at wide ones, and the check is tab order, .focus() and the accessibility snapshot — measured at 390, 900, 901 and 1280, because the regression lives on one side of a media query.

-

So the rule from §6 has a second half: a measurement of the wrong property is not a measurement either, however precise it is. checkVisibility fixed the visibility instrument; it does not turn a visibility count into an operability check. Ask what the defect would do to a user, then pick the instrument that observes that.

-

A correction, and the risk the original argument missed. The commit that added content: "\2630 " / "" said Firefox ignores the alt-text form and falls back to reading the content, so the change was "an improvement in two engines and a no-op in the third". That mechanism is wrong — Firefox shipped the syntax in 133, and all three engines parse it (measured: Chrome 141, Firefox 142, WebKit 26 each compute the declaration intact). The conclusion held; the reason given for it did not, and the reason was the load-bearing part, because it was what made the change look like a one-way door. It is one because every engine shipped to supports the syntax — not because the unsupported path is harmless. That commit message is superseded by this paragraph (design-principles §6: an argument must not live only in a commit message).

-

The unsupported path is not harmless, and that is the risk neither the change nor its first review named. / alt in an engine that does not know it is a parse error for the whole content declaration, so the glyph would not lose its alt text — it would vanish. A silent visual regression, the opposite of the assumed failure mode. The glyph is therefore verified painted in all three engines by pixel diff against ::before { content: none }, with the text starting 20px further right in each.

-

That check also produced this file's fourth instance of the wrong-property trap, and the sharpest, because it produced a confident false positive: the first probe measured the label's width with and without ::before and reported the glyph missing in all three engines. The label is display: block, so its width comes from the container and cannot see inline content at all. Right question, wrong property, and this time the wrong instrument accused working code rather than clearing broken code.

-
-
- - diff --git a/site/docs/index.html b/site/docs/index.html deleted file mode 100644 index ff7687b7..00000000 --- a/site/docs/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - -Documentation — improvise docs - - - -
- -
-

improvise — documentation

-

These are the working documents of the improvise repository, published as written. They are addressed to someone reading or changing the code: the arguments behind the architecture, the decisions that were made and the ones that were rejected, and the record of what each load-bearing test is guarding against.

-

Nothing here is a tutorial. For what the program does, see the project home or run the browser demo.

-

#Start here

What the project is for, and the principles the code is held to.

#Decision records

Decisions with consequences, including the options that were rejected.

#The arguments, by area

The reasoning that used to live in code comments. Written for a reader with the source open.

#Test evidence

For each test whose narrative is longer than its code: what broke, what fault was injected, and why the test can fail. Contributor reference, not a tour.

#About this site

What is published here, what is not, and why.

-

#Conventions

-

Ids like improvise-9c8c are tracker issues and link to the ticket index below. Paths like crates/improvise-core/src/model/types.rs link to the source on GitHub at main.

-
-
- - diff --git a/site/docs/repo-map.html b/site/docs/repo-map.html deleted file mode 100644 index d6593f51..00000000 --- a/site/docs/repo-map.html +++ /dev/null @@ -1,448 +0,0 @@ - - - - - -Repository map — improvise docs - - - -
- -
-

Source: context/repo-map.md

On this page

Gotchas: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849

Repository Map (LLM Reference)

-

Terminal pivot-table app. Rust 2024, Ratatui TUI, command/effect architecture. Apache-2.0. Root binary+lib improvise; sub-crates under crates/:

-
    -
  • improvise-core — Model, View, Workbook, number formatting. No UI/IO.
  • -
  • improvise-formula — formula parser, AST, parse_formula.
  • -
  • improvise-io — .improv save/load, CSV/JSON import, Storage backends. No UI/commands.
  • -
  • improvise-protocol — the wire vocabulary and the portable session slice: Command, Projection, ViewState, the per-widget caches, reduce_view, key types. Depends only on improvise-core + serde. No IO, no command layer, no terminal.
  • -
  • improvise-command — command/effect layer + semantic App state (app.rs, effect.rs, cat_tree.rs, viewport.rs). Depends on improvise-protocol. No ratatui/crossterm.
  • -
  • gp-tree — problem-agnostic genetic-programming regression trees (Tree, Spec, breed, simplify, render). Knows nothing about improvise.
  • -
  • improvise-gp — the one seam between the two: emits a fitted gp_tree::Tree as improvise formula text (formula_text / fitted_formula). Depends on gp-tree, improvise-formula and improvise-core — dataset.rs builds a feature matrix from a View's records (improvise-4s8) and fitted_formula takes the &Model it must resolve against (improvise-8xhy). Nothing depends on it yet.
  • -
  • improvise-ws-server — the host: tokio + tungstenite, a Registry of Hosts keyed by DocumentId (multi-document, ADR 001), Sessions fan-out, and the socket framing (wire.rs). Native only.
  • -
  • improvise-worker-server — the other host: the same App, Sessions and Host as the socket server, behind self.onmessage/self.postMessage with OPFS underneath. wasm32 only in anger; worker.rs/vfs.rs are ordinary Rust and are where every decision and every test is. Nothing depends on it.
  • -
  • improvise-wasm-client — the browser client: the fold, the key classifier, the socket framing it mirrors (socket.rs), and the render model the DOM paints (render.rs). Non-dev deps are improvise-protocol + serde + wasm-bindgen and nothing else. The page itself is web/.
  • -
-

The main crate (and improvise-command) re-export the sub-crates as crate::{model, view, workbook, format, formula, persistence, import} so consumer paths stay stable when crates shuffle. The main crate also re-exports improvise_command::command as crate::command and improvise_command::ui::{app, cat_tree, effect, viewport} under crate::ui. ui::app re-exports the protocol's AppMode, ViewState, ViewCursor, ViewFrame, Panel, MinibufferConfig, MinibufColor and HELP_PAGE_COUNT, and command::key re-exports the protocol's key types, so every pre-existing path still resolves.

-

Architectural intent lives in context/design-principles.md — read that for the "why". This doc is the "where".

-
-

#How to Find Things

-
I need to...Look in
Add a keybindingimprovise-protocol/src/keymap.rs → Keymaps::default_keymaps() (the table; the browser looks up the same bindings). Resolving a bound name into effects is improvise-command/src/command/keymap.rs.
Add a user commandcommand/cmd/<submodule>.rs, register in registry.rs
Add a state mutationui/effect.rs → implement Effect
Change formula evalmodel/types.rs → eval_formula / eval_expr
Change cell storage / lookupmodel/cell.rs → DataStore
Change category/item behaviormodel/category.rs → Category
Change view axis logicview/types.rs → View
Change an axis label, color or cycleview/axis.rs → impl Axis (colors resolve in draw.rs::axis_fg)
Ask "what data is visible" vs "what can the user page"View::filtering_cats() vs categories_on(Axis::Page)
Change grid layoutview/layout.rs → GridLayout
Change .improv formatpersistence/improv.pest + persistence/{format,parse}.rs
Change where documents are storedstorage.rs → Storage impls (opfs.rs for wasm)
Change number displayformat.rs → format_f64
Change CLI argsmain.rs (clap)
Change import logicimport/wizard.rs → ImportPipeline
Change how a gp-tree fit searchesgp-tree/src/fit.rs → FitConfig / fit
Change gp-tree CSV preprocessinggp-tree/src/data.rs → preprocess
Change how a name is quoted anywhereimprovise-formula/src/quote.rs — the one home, both halves
Change import-wizard keys/stepscommand/cmd/wizard.rs (keys) + import/wizard.rs (steps)
Change frame layoutdraw.rs → draw()
Change what the browser page drawsimprovise-wasm-client/src/render.rs → RenderModel
Change a browser↔server frameimprovise-protocol/src/frame.rs → ClientFrame/ServerFrame. The one pair; both ends pub use it and neither defines one (improvise-xks6)
Change the worker's message loopimprovise-worker-server/src/worker.rs → Worker::on_frame
Change the session loop (either host)improvise-command/src/ui/host.rs → Host::handle
Change how the browser opens or seeds a documentimprovise-worker-server/src/vfs.rs → open / documents
Change what a browser resolves without the hostimprovise-protocol/src/resolve.rs → resolve. Not wasm-client/src/keys.rs, which holds only BrowserKey (KeyboardEvent → improvise key) — this row named it until 2026-09-21 and that pointer is how a false claim reached context/ (improvise-e5pf)
Change the browser page itselfweb/app.js (thin), web/index.html, web/style.css
Host another documentimprovise-ws-server/src/registry.rs → Registry, ClientId
Change app state / modesui/app.rs → App, AppMode
Write a test for model logicmodel/types.rs → mod tests / formula_tests
Write a test for a commandcommand/cmd/<module>.rs → colocated mod tests; helpers in cmd/mod.rs::test_helpers
-
-

#Central Pattern: Cmd → Effect

-
keypress → Keymap lookup → Cmd::execute(&CmdContext) → Vec<Box<dyn Effect>> → Effect::apply(&mut App)
-                            (pure, read-only ctx)       (list of mutations)    (only mutation site)
-
pub trait Cmd: Debug + Send + Sync {
-    fn name(&self) -> &'static str;
-    fn execute(&self, ctx: &CmdContext) -> Vec<Box<dyn Effect>>;
-}
-
-pub trait Effect: Debug {
-    fn apply(&self, app: &mut App);
-    fn kind(&self) -> EffectKind;              // NO default — the compiler makes you classify
-}
-
-// EffectKind::{ViewOnly, Model(ModelChange)}
-// ModelChange::{CellData, LayoutGeometry, WholeWorkbook}
-

CmdContext (see command/cmd/core.rs) holds immutable refs to Model, GridLayout, AppMode, cursor/offsets, buffers, yanked cell, key code, expanded cats, and panel/tile cursors.

-

Add a command: implement Cmd in a command/cmd/ submodule, register in registry.rs, bind in default_keymaps(). Simple wrappers go through the effect_cmd! macro in effect_cmds.rs.

-

Add an effect: implement Effect in ui/effect.rs, add a constructor fn if it helps composition.

-
-

#Key Types (skim; read the source for fields/methods)

-

Model (model/types.rs): categories: IndexMap<String, Category>, data: DataStore, formulas: Vec<Formula>, views: IndexMap<String, View>, active_view, measure_agg. MAX_CATEGORIES = 12 (regular only). Virtual categories _Index, _Dim, _Measure always exist. Use regular_category_names() for user-facing pickers.

-

CellKey (model/cell.rs): Vec<(cat, item)> — always sorted by category. Build with CellKey::new(coords) / with(cat, item) / without(cat). Never construct the inner Vec directly.

-

CellValue: Number(f64) | Text(String) | Error(String). Errors surface from formula eval (circular, div/0, etc.).

-

DataStore (model/cell.rs): interned symbols + secondary index (Symbol, Symbol) → {InternedKey}. Hot-path lookups go through matching_values / matching_cells.

-

Formula AST (improvise-formula/src/ast.rs):

-
    -
  • Expr { Number, Ref, BinOp, UnaryMinus, Agg, If }
  • -
  • BinOp { Add, Sub, Mul, Div, Pow, Eq, Ne, Lt, Gt, Le, Ge }
  • -
  • AggFunc { Sum, Avg, Min, Max, Count }
  • -
  • Formula { raw, target, target_category, expr, filter }
  • -
-

View (view/types.rs): category_axes: IndexMap<cat, Axis>, page selections, hidden items, collapsed groups, number_format, prune_empty. It carries no cursor — selected/row_offset/col_offset moved to ViewState::cursors (improvise-8uz). Axis { Row, Column, Page, Filter, None }. cycle_axis rotates Row → Column → Page → None; Filter is outside the rotation (see gotcha 27).

-

GridLayout (view/layout.rs): pure function of Model + View. cell_key(r,c), cell_value(r,c), drill_records(r,c). Records mode auto-detects when _Index is on Row and _Dim is on Column.

-

AppMode (ui/app.rs): 17 variants (Normal, Editing, FormulaEdit, FormulaPanel, CategoryPanel, ViewPanel, TileSelect, CategoryAdd, ItemAdd, ExportPrompt, SavePrompt, CommandMode, ImportWizard, Help, Quit, RecordsNormal, RecordsEditing). Eight text-entry variants carry a MinibufferConfig. SearchMode is Normal + search_mode: bool, not its own variant. Per-variant UI facts live on the enum as exhaustive-match methods — label(), hint_text(), panel(), minibuffer(), is_editing(), is_records() (improvise-2hi); add a variant and the compiler names every one you must fill in. mode_style() stays in src/draw.rs because Style is a ratatui type. App state is split into ModelState (workbook, file_path, dirty) and ViewState (mode, cursor/offsets, buffers, panels, search) — see improvise-x2c/ew0. wizard and drill_state live on App, not ViewState (improvise-h0b) — see gotcha 24.

-

Keymap (improvise-protocol/src/keymap.rs; dispatch and KeymapSet in improvise-command/src/command/keymap.rs): Binding { Cmd | Prefix(Arc<Keymap>) | Sequence(Vec<…>) }. Lookup fallback: exact(key,mods) → Char(NONE) → AnyChar → Any → parent. 17 mode keymaps, one per ModeKey, built by Keymaps::default_keymaps(); mode resolved via ModeKey::from_app_mode(). ModeKey and AppMode are two different enumerations that happen to be the same size — SearchMode is a ModeKey and not an AppMode; Quit is an AppMode with no keymap — so do not "fix" one count to match the other.

-
-

#.improv File Format

-

Plain-text, markdown-like, defined by persistence/improv.pest. Parsed by pest — the grammar is the single source of truth (the grammar-walking test generator reads it via pest_meta).

-
v2025-04-09
-# Model Name
-Initial View: Default
-
-## View: Default
-Region: row
-Measure: column
-|Time Period|: page, Q1
-hidden: Region/Internal
-collapsed: |Time Period|/|2024|
-format: ,.2f
-
-## Formulas
-- Profit = Revenue - Cost               # defaults to [_Measure]
-- Tax = Revenue * 0.1 [CustomCat]
-
-## Category: Region
-- North, South, East, West              # bare items, comma-separated
-- Coastal_East[Coastal]                 # grouped item, one per line
-> Coastal                               # group definition
-
-## Data
-Region=East, Measure=Revenue = 1200
-Region=West, Measure=Revenue = |pending|   # pipe-quoted text
-
    -
  • Name quoting: bare [A-Za-z_][A-Za-z0-9_-]*, else CL-style |…| with escapes \|, \\, \n. Same convention in the formula tokenizer.
  • -
  • Write order: Views → Formulas → Categories → Data. Parser tolerates any order.
  • -
  • Gzip: .improv.gz (same text, gzipped).
  • -
  • Legacy JSON: auto-detected by leading {; never written.
  • -
  • Axis keywords: row, column, page, filter, none; page/filter carry the selected item after a comma.
  • -
  • Virtual categories: _Index/_Dim never persist; _Measure persists only non-formula items (formula targets are rebuilt from the ## Formulas section).
  • -
  • Virtual views: _Records/_Drill (view::VIRTUAL_VIEW_NAMES) are never written and are dropped on load if an older file carries them; saving from records/drill mode writes the first real view as Initial View. Their commands rebuild them on demand.
  • -
-
-

#Gotchas (read before editing)

-
    -
  1. Records mode is wide format. A row is one coordinate tuple — the cells sharing everything but _Measure — and the columns are [coordinates…, **+cat**, raw measures…, +]: a column per regular category, then the column that creates a new category (improvise-2zq, sitting where the column it creates will appear), then a column per raw measure, then the column that creates a new measure (improvise-c5v). Consequences: two measures on one coordinate are ONE row; editing a category column moves every measure cell in the row, not just the one under the cursor; _Dim in the synthetic key carries the column index, not a label, because a category and a measure may share a name — resolve it with GridLayout::record_col into a RecordCol. Formula targets are not columns (nothing to type into them). Drill pending edits are keyed by (row, RecordCol). prune_axis_items takes a predicate, not a flag vector. prune_empty hands it impl Fn(usize) -> bool over the data index (group headers consume no index). The domain is derived from items itself, so there is no parallel length a caller could get wrong — the earlier &[bool] + debug_assert pair could disagree between debug and release if a third caller ever sized keep from something other than row_count()/col_count() (improvise-n3wt). GridLayout::new does not prune; with_frozen_records does — and that split has now produced four bugs: view_frame, project::grid_in (PR #78), and persistence/export.rs::render_csv (improvise-iot6), which wrote rows and columns the grid does not draw for as long as CSV export has existed. Every remaining GridLayout::new call site in the workspace is test code — verified — so a new production caller almost certainly wants with_frozen_records(m, v, None).
  2. -
  3. Effects never touch std::fs. Every document read or write goes through App::storage: Box<dyn Storage> (improvise-fuj). The host picks the backend: App::new supplies PhysicalFs, App::with_storage takes any other — a browser worker will supply OPFS (improvise-i34), and tests use MemoryFs. improvise-io keeps path-taking wrappers (save, load, export_csv, parse_csv_with_warnings) for the native CLI, each delegating to a _to/_from/_bytes variant that takes the backend or the bytes.
  4. -
  5. Every effect declares an EffectKind, and there is no default. ViewOnly never reaches other clients; Model(CellData | LayoutGeometry | WholeWorkbook) does (improvise-mae). A new effect that forgets to classify itself fails to compile — that is deliberate, since the failure mode is silent: a mis-declared ViewOnly edit would simply never reach connected clients. Watch two traps: the View lives inside model_state.workbook, so "touches model_state" does NOT mean "is a model change" (axis/page/format are LayoutGeometry, cursor and scroll are ViewOnly — see improvise-8uz; and SwitchView, ViewBack and ViewForward moved from LayoutGeometry to ViewOnly with improvise-jytm, writing view_state and nothing else, which is what took the latter two off the wire — but a sound derivation is not a guard: only SwitchView had one, and review showed reclassifying either sibling back was silent across the whole suite); and Save/SaveAs/MarkDirty touch model_state for bookkeeping only and are ViewOnly. AddCategory/RemoveCategory are the third trap and go the other way: they look like data edits, but both go through Workbook, whose on_category_added/on_category_removed rewrite every view's axis map, so they are LayoutGeometry. The two "an effect declaring X actually changes X" tests cannot separate them — a category change moves model data and view config, so it clears either bar; structural_effects_declare_their_kind pins the declaration itself. A third trap, now guarded: EffectKind is too coarse to check a mapping. ui/reduce.rs::model_effects sends each of 28 ModelCommand variants to one effect, and every inverse pair — HideItem/ShowItem, AddItem/RemoveItem, CreateView/DeleteView, SetCell/ClearCell — declares the same kind, so swapping three of them at once left the suite at 918 passing while three wire messages did the exact inverse of what they said. The mapping is name-for-name by construction, so every_model_command_names_the_effect_of_the_same_name asserts that, reading the type name off the effect's derived Debug (Effect is a trait object, so type_name is out of reach). A variant whose effect must be named something else is a variant that stopped being derived — it should fail there and be argued about. Their reach is wider than the name suggests: Model::remove_category also drops every formula targeting the category, which is why Dirty::of(LayoutGeometry) reports formulas: true — see gotcha 35.
  6. -
  7. Commands never mutate. &CmdContext is read-only; return Vec<Box<dyn Effect>>. If you want to touch &mut App, add or extend an Effect.
  8. -
  9. CellKey is always sorted. Use the constructors; equality and hashing rely on canonical form.
  10. -
  11. No Model::add_item. With a Workbook in hand use the facade: wb.add_item("Region", "East")? / add_item_in_group / remove_item (improvise-tvt). With only a Model, go through the category: m.category_mut("Region").unwrap().add_item("East").
  12. -
  13. Virtual categories. _Measure items = explicit items ∪ formula targets. Use measure_item_names() / effective_item_names("_Measure"). add_formula does not add items to _Measure. Formula-target lookup also falls back into _Measure via Model::find_item_category. Cell aggregation goes through apply_agg, formula SUM(…)/AVG(…) through eval_agg — they differ on empty input (absent vs. A third rule joins them for non-numeric values: aggregate_cell_values reduces text by unanimity — one matching value, or several copies of the same value, is that value; differing values, or text among numbers, is CellValue::Error("mixed") (improvise-l4qx). The all-numeric branch is checked first, so Count of a lone number is still 1 and not the number — that ordering is the whole difference between this rule and "a single value passes through". What the rule forbids is dropping: filter_map(as_f64) used to, and since _Index/_Dim are on Axis::None in every default view that made every text cell in every document render blank, including the pipe-quoted text .improv carries as a first-class value. evaluate_aggregated and aggregate_raw share the helper, so a formula ref over mixed values now errors "mixed" rather than silently summing the numeric ones. 0 for Sum/Count, error for Avg/Min/Max). Model::evaluate_aggregated takes Aggregation::{Exact, OverHidden} rather than none_cats: &[String] (improvise-5jm5) — the list was only ever tested for emptiness, and the guard against that shape returning is the signature. recompute_formulas/aggregate_raw keep the list because they walk it.
  14. -
  15. Display rounding is view-only. format_f64 (half-away-from-zero) is only called in rendering. Formula eval uses full f64. Never feed a rounded value back into eval.
  16. -
  17. Formula eval is fixed-point. recompute_formulas(none_cats) iterates until stable, bounded by MAX_EVAL_DEPTH; circular refs converge to CellValue::Error("circular"). App::new runs it before the first frame so formula cells render on startup.
  18. -
  19. Drill into formula cells strips the _Measure coordinate when it names a formula target, so matching_cells returns the raw records that feed the formula instead of returning empty.
  20. -
  21. Minibuffer keymaps come from TextEntry. The eight text-entry modes are built by the TextEntry builder in improvise-protocol/src/keymap.rs — seven by TextEntry::new, and RecordsEditing by TextEntry::with_parent off Editing's map, which is why a count taken off the ::new call sites reads seven. It binds the shared spine (AnyChar→append-char <buf>, Backspace→pop-char <buf>, Esc→[clear-buffer <buf>, enter-mode <exit>]) and welds every commit key to its clear-buffer via .commit(key, cmd, args) (improvise-w3q). TextEntry::new takes the AppMode and reads <buf> off its MinibufferConfig, so the buffer the prompt names and the buffer the keys edit are the same string by construction (improvise-k8h) — the keymap never spells a buffer name. .rebind exists only for CommandMode's Backspace. SearchMode is hand-written on purpose — no commit key, and exit-search-mode owns the query. EnterMode clears the incoming mode's minibuffer through write_buffer too — building SetBuffer directly there was unreachable only because no shipped AppMode is keyed "search". MinibufferConfig::buffer_key is a String, not &'static str: a borrowed-'static field makes Deserialize unimplementable, and every text-entry AppMode variant carries a MinibufferConfig (improvise-a2x). A buffer named at runtime is read with cmd::core::read_buffer and written with its mirror write_buffer — "search" is not in the buffers map but its own ViewState field, and those two functions are the only code that knows it (improvise-gdl).
  22. -
  23. Panel keymaps come from PanelKeymap. The three panel modes share a spine (Esc/Tab→Normal, arrows and j/k→move-panel-cursor <panel>, every panel's toggle key, :→command mode) built by PanelKeymap::new(panel); the keys that act on the panel's contents arrive through .bind/.bind_args/.delete because they vary in shape, not just command name (improvise-4pg). Panel::ALL/name()/key() in ui/app.rs drive the toggle keys for Normal and all three panels, so a fourth panel reaches every keymap from one ALL entry. name() must spell what parse_panel accepts.
  24. -
  25. Prefix keys are stored as an address, not a keymap. ViewState::transient_prefix holds the (KeyCode, KeyModifiers) that opened a prefix; KeymapSet::prefix_keymap resolves it back to the sub-keymap, walking parents so RecordsNormal reaches Normal's g/y/Z (improvise-jc8) — that is the command-layer wrapper; the resolution itself is Keymaps::prefix_keymap in improvise-protocol/src/keymap.rs. ViewState must stay free of improvise-command types — it is destined for the protocol crate, which improvise-command will depend on. Resolution assumes prefixes are flat; prefix_keymaps_are_flat fails the build if a nested one is added.
  26. -
  27. Keybindings are per-mode. ModeKey::from_app_mode() resolves the mode; Normal + search_mode=true maps to SearchMode. Minibuffer modes bind Enter/Esc as Binding::Sequence so clear-buffer fires alongside commit/cancel. Hints are checked against the keymaps: every_hint_key_resolves_in_its_mode (command/keymap.rs) splits each AppMode::hint_text() and requires every key it names to resolve in that mode's map, because only_quit_has_no_hint asserts only that a hint is present. It checks which keys are named, not what they do. Its tokenizer knows the hint dialect explicitly (named keys, Ctrl-<c>, F/C/V alternatives, character runs like hjkl/[:], the em-dash prose tail, CommandMode's :q ex-commands) and panics on a token it cannot classify rather than skipping it.
  28. -
  29. Registry names come from prototypes, not strings. Every register* call reads the name off a prototype Cmd instance, so a typo is impossible (improvise-9cn) — there is no name literal in registry.rs. What types can't reach: four commands name themselves off a field (ViewNavigate, TileAxisOp, ToggleGroupAtCursor, CommitAndAdvance), so a prototype can name the wrong variant and still compile. That shows up as a duplicate registration, not a mismatch — the wrong-variant entry collides with its sibling and the sibling's name vanishes. registered_names_are_unique catches it; every_bound_command_name_is_registered catches the keymap side, and checks both halves of dispatch's registry.interactive(name, args, ctx).ok()? — the name resolves and the args parse — because improvise-s13 moved enter-mode ["export-prompt"]/["formula-edit"] out of the nullary class, where a typo'd mode name compiles into a dead key. It builds the CmdContext in each keymap's own AppMode, since commit-buffer item reads ctx.mode.
  30. -
  31. effect_cmd! macro wraps a pre-built effect as a parseable command. Use it for simple wrappers; reach for a hand-written Cmd once there's real decision logic. Its counterpart on the effect side is simple_effect! in ui/effect.rs, which declares a one-field newtype that writes one place on the App. Its kind argument is mandatory — simple_effect!(Name(T) => |app| place, EffectKind::ViewOnly) — even though every call site so far answers ViewOnly: hard-coding it reintroduced exactly the default gotcha 3 does without, since $place is an unconstrained expression and a one-line active_view_mut().number_format setter would have reported ViewOnly with the suite green (improvise-8fy). Likewise, mode entry has no per-mode shim commands — bind enter-mode <mode-name> (improvise-s13); the name is decoded by parse_mode_name in registry.rs, and EnterMode clears the mode's minibuffer on the way in.
  32. -
  33. Float equality inside formula eval has one epsilon, and the div/0 guard shares it. FLOAT_EQ_EPSILON = 1e-10 in model/types.rs is used by =/!= and by the division-by-zero guard (rv.abs() < FLOAT_EQ_EPSILON), in both eval_expr and its aggregated twin — six uses across the two evaluators (a div/0 guard plus the Eq and Ne arms in each), one constant. This gotcha previously recorded the opposite (=/!= epsilon, div/0 exact) and was stale; the mix is gone. Why an epsilon at all and why this one: context/code/core.md §4.
  34. -
  35. IndexMap preserves insertion order for categories, views, and items. Persistence relies on this — and so, since improvise-shyt, do DataStore::matching_cells/matching_values: the secondary index narrows the candidates, then matching_positions resolves each to its position in the primary IndexMap, sorts, and reads back. An index whose values are HashSets is for O(1) lookup, never for iteration. Walking it gave a different order in every process, which was not tidiness. Records-mode rows reordered between sessions; SUM/AVG drifted in the last bits because f64 addition is not associative (Min/Max/Count and the text-unanimity rule of gotcha 7 are order-free, so they were never affected); and worst, the drill snapshot froze its record list in hash order while drill_state.pending_edits are keyed by row index (gotcha 1), so the same drill opened in a second process addressed different records — silent misdirected writes, and nothing in the tracker knew. Measured at 18,000 cells: +20% worst case on matching_values, and matching_cells 7% faster, sorted positions resolving CellKeys with better locality. DataStore::live_records was the open choice and is now decided (improvise-2yc2): it walks cells and projects insertion order onto records, deliberately paying O(cells) rather than O(records), because cells' order is already the answer matching_positions gives and a second answer to "what order is this store in" is the disease. It is derived per call rather than memoised on RecordEntry, since a stored position is a copy and a record deleted and re-created would hold its old one. Note what is not on offer: an unordered return — every iterator has an order, and a type that merely disclaims one still hands the caller hash order, which is the guard living one level away from the hazard and the shape this whole class of bug returns through. The second real instance was import::analyzer::analyze_records (improvise-7yx), which built its distinct values through a HashSet: build_model adds items in exactly that order and IndexMap preserves it, so the same CSV imported twice produced two different .improv files and two different row orders. Now first-seen order, pinned by a five-iteration test because each HashSet gets its own RandomState. Also answered here since: an empty partial matches every cell vacuously (improvise-hkt6) — it used to index sets[0] and panic, unreachable only because both callers guarded.
  36. -
  37. Commit paths for cell edits split across commit_cell_value → commit_regular_cell_value / commit_plain_records_edit in command/cmd/commit.rs. Keep the synthetic-records branch in sync with the plain branch when changing behavior.
  38. -
  39. Minibuffer commits go through one command. commit-buffer <formula|category|item> (CommitBuffer { kind: CommitKind }) owns buffer→trim→bail-on-empty→dirty→status for all three; the varying parts are methods on CommitKind. The add effect is emitted last so its failure message wins over the command's optimistic status (improvise-6t3). CommitKind::Item carries its target category, resolved from AppMode::ItemAdd by parse_commit_kind at registry time — so commit-buffer item is not constructible from a script line.
  40. -
  41. An abort stops the chain, never the bookkeeping — and ordering is what separates them. Three effects set abort_effects, and they are not all saves: Save and SaveAs on a failed write or a missing path (improvise-8tk — SaveAs was the arm left out of the original fix, so :w /bad/path . q quit on a failed write), and AddCategory when Model::add_category refuses, because what follows assumes the category exists — from the +cat column (improvise-2zq) the rest of the batch would advance the cursor and re-open an editor on a column that was never created. A Binding::Sequence flattens into one effect vec, so the abort carries across sequence steps; that is a property of the flattening, and it is why improvise-0hb's proposal to apply effects between steps was rejected — apply_effects resets the flag per batch, so step 2 in a fresh batch would land the Quit after a failed Save, silently reopening a P1. a_sequence_whose_first_step_aborts_does_not_run_the_second is the guard. The rule the aborts taught, which is the part to carry: an abort at the tail of a batch also ate the mode restoration and the buffer clearing, stranding the user in CommandMode/Editing/SavePrompt — where draw_bottom_bar renders the minibuffer instead of the status line, so the error the effect had just written was never drawn and the operation appeared to do nothing at all. So tidy-up goes at the head, where an abort cannot reach it: ExecuteCommand fronts its batch with ReturnToNormalUnlessMoved, CommitAndAdvance with the mode change, CommitSave with the mode change (found by Copilot on PR #27). Ordering answers it without giving any effect a "runs anyway" flag, and it does not weaken gotcha 31's observed-not-predicted mode settling: whatever a later effect sets, it sets after, so the mode a : line ends in is unchanged in every case — the only difference is what happens when the batch never reaches the end. An abort is not a rollback; that is the hole clause 4 leaves (gotcha 47).
  42. -
  43. The import wizard's back path is a history stack, not a reverse table. advance pushes the step it leaves onto ImportWizard::history; previous pops it, so back always retraces the path actually taken through the conditional transitions (improvise-907). Preconditions live in validation_error(), checked on the step where the user can still fix them — never deferred to build_model. NameModel is terminal for advance: Enter there calls build(), which parks the workbook in built on Done, and AcceptWizardModel takes it from there. Going back from Done discards built.
  44. -
  45. app.layout goes stale mid-batch. apply_effects rebuilds only after the whole batch, so an effect that reads the layout after an earlier effect changed the grid needs a RebuildLayout effect in front of it — the command decides the order (see EnterEditAtCursorCmd, CommitAndAdvance). Read the cursor's cell text via App::display_value_at_cursor(); don't build a CmdContext inside apply (it recomputes column widths).
  46. -
  47. The cursor is per-client and keyed by view. ViewState::cursors: HashMap<String, ViewCursor> holds selected/row_offset/col_offset, keyed by view name; an absent key means the origin. Reach it through App::cursor() (a Copy snapshot), App::cursor_mut(), and App::reset_cursor() — never off View, which no longer has the fields (improvise-8uz). Two consequences. Changing the grid's shape must reset the cursor explicitly: View::transpose_axes/cycle_axis used to do it themselves, and now TransposeAxes/CycleAxis call app.reset_cursor() — a new geometry effect that renumbers rows or columns has to do the same. Replacing the document must clear the map: LoadModel and AcceptWizardModel call cursors.clear(), because the incoming workbook's view names have nothing to do with the outgoing one's. That clearing replaces Workbook::normalize_view_state, which is gone — a View can no longer carry a stale offset. The same clearing owes the session mirrors: install_workbook bumps App::document_generation, and ClientSession::project reseeds any mirror seeded at an earlier one (improvise-cqq). An effect cannot reach Sessions, so the generation is the mechanism and Sessions::rebase is only the client-facing half. The same is true of a geometry change, and it owes the mirrors both halves: App::cursor_epoch is bumped by reset_cursor and watched by Sessions::adopt_cursor_epoch, which resets every mirror and returns Broadcast::ToAll(Command::ResetCursor) (improvise-gvnb). Doing only the first is strictly worse than doing nothing — the diff then believes every client already holds origin-positioned content and sends nothing, so they stay stale permanently rather than until their next scroll. The trigger is the epoch moving, not the command variant, for the reason gotcha 39 watches document_generation instead of matching on LoadModel: which effects renumber the grid is a per-effect decision, so a table of variants would be a second description of it, free to disagree. What is broadcast is an invalidation, never a position — another client's transpose must not move where I am looking (that is UI and it is mine, improvise-g0f), only declare that where I was looking has stopped meaning anything. Since improvise-ni6k no effect calls reset_cursor at all — the cursor is invalidated by OBSERVING the grid's frame. apply_effects diffs the frame after every model effect, inside the loop, because a batch that reframes and then places the cursor loses the placement otherwise (four records-mode tests pin that position). There is no list of effects anywhere, so the mechanism is total over EffectKind::Model(_) by construction — a genuinely stronger shape than a hand-kept table, and the reason the old "a geometry effect that forgets reset_cursor is invisible in both directions" hazard is closed.

    -

    Two arms, and the asymmetry is the whole design (improvise-mcq4). LayoutGeometry hashes only the active view — it is the held-key path, and other views cannot reframe there since view_mut() writes only the active view, with the category and item effects already covered by view_shapes. CellData hashes every view, because a cell write can prune a row out of a view the host is not on; narrowing that arm to one view measured as free and silently lost a multi-client invalidation, which is gotcha 24's own strictly-worse-than-nothing case. Cost of the wide arm: 0% on the held-key path, +5.7% on an 18k two-view SetCell, linear in view count, accepted. State the absolute as well as the ratio: the observation roughly doubles held-key latency on large pruned documents (3.04 → 5.69 ms at 18k) and 56 ms at 90,000 cells is 3.4 frames.

    -

    Totality is conditional on classification, and nothing makes classification total: a ViewOnly effect that renumbers the grid is invisible to all of this (gotcha 3's hole, unchanged). And two known gaps are filed rather than fixed — a geometry effect immediately after a mid-batch SwitchView skips the check entirely, because there is no basis for the new view yet (improvise-9c9o; under-reporting, which gotcha 35 says is the expensive direction), and Model(WholeWorkbook) matches neither arm, which is benign only because all three such effects go through install_workbook → rebase → a fresh Snapshot.

  48. -
  49. ViewState and its field types are Serialize/Deserialize. It is the wire payload of the client Snapshot (improvise-q08), so AppMode, MinibufferConfig, MinibufColor, ViewFrame, KeyCode and KeyModifiers all derive both (improvise-a2x), and improvise-command depends on serde for it. The constraint this puts on new state: no borrowed-'static fields anywhere ViewState can reach — Deserialize cannot produce one, and the error surfaces on every enclosing variant rather than at the offending field. view_state_survives_a_json_round_trip guards it. ViewState and Snapshot also derive Clone and PartialEq, so the bootstrap message can be compared in a round-trip test.
  50. -
  51. Axis presentation lives on Axis, colors resolve in draw.rs. Four vocabularies hang off the enum in view/axis.rs — plain_label() ("Row"/"Col"/"Page"/"None"), glyph() (which Display appends, giving "Row ↕"), short_label() (three columns for the tile bar; None is "·", not a truncation), and initial() (one letter for the view panel; None is empty). Plus color() -> AxisColor and next() for the Row→Column→Page→None rotation. AxisColor is a semantic palette: improvise-core must not name a ratatui type, so draw.rs::axis_fg does the one mapping to a terminal color, exactly as minibuf_fg does for MinibufColor (improvise-rml). The tile bar and category panel each carried their own copy of the color table before, with nothing keeping them in agreement. Not covered here: persistence/format.rs maps Axis to the on-disk keywords ("row"/"column"/"page"), which is a format concern and stays in the io layer.
  52. -
  53. Axis::Filter constrains without being navigable. Drilling fixes its coordinates on Filter, not Page (improvise-dz8): both restrict which records are visible, but only Page is something the user may move or page through. The split shows up as two different questions, and picking the wrong one is the trap: "what data is visible" is View::filtering_cats() (Page ∪ Filter — used by GridLayout and by add-record-row when it builds a key from the current constraints), while "what can the user act on" is categories_on(Axis::Page) (used by [/] page cycling) or View::tileable_cats() (used by all three tile-select sites). Axis::next() maps Filter to itself and no navigable axis rotates into it, so tile select cannot hand one out. The .improv grammar accepts filter and round-trips a selection with it — a Filter axis only ever occurs in the never-persisted _Drill view, but the alternative was writing it out as some other axis.
  54. -
  55. The browser backend is sync on purpose, with an async seam. OpfsFs (improvise-io/src/opfs.rs, wasm32 only) implements the same synchronous Storage trait as PhysicalFs, because FileSystemFileHandle.createSyncAccessHandle() yields a handle whose read/write/truncate/flush are blocking — available only inside a dedicated Web Worker, which is where improvise-9x6 puts the app. So Storage never became async. What is still async splits out into four explicit methods the worker awaits from its message loop: open(), attach(path) (must precede any read/write of that document — acquiring a handle is async even though using it is not), refresh() (repopulates the index list answers from), and commit_deletes(). Consequence: delete is visible immediately but lands in OPFS at the end of the batch — it drops the name from the index and queues a removeEntry. Handles are exclusive per file, so attach closes any handle it replaces and detach releases the lock. storage_contract in storage.rs is the behaviour every backend owes; it runs against both host backends and is the written-down spec OpfsFs must satisfy in a browser, where the harness cannot reach.
  56. -
  57. "Which view is active" is stored twice, and the copies are now allowed to differ. Workbook::active_view is the document's default — what a freshly connected client opens, and what .improv writes as Initial View. ViewState::active_view is authoritative for that client, and it is what keys ViewState::cursors. The second copy exists because reduce_view runs on a client that holds no Workbook (improvise-cqi); the split is improvise-jytm answering improvise-g0f, on the user's ruling that the model is shared and the UI is not. Two clients may sit on different views of one document. Three things follow. App::resolve_view is the one answer to what a view name names in this document, falling back to the default so a slice naming a deleted view cannot panic in rebuild_layout; view(), view_mut(), view_name(), project::grid_in and ClientSession::view_in all go through it, and a second copy of that fallback was the first thing review found. The guard was replaced, not deleted — the_two_active_view_names_never_diverge became the_documents_default_view_outlives_this_clients_switches plus a_fresh_session_opens_the_documents_default_view, because a deliberate split with no guard is how one silently regresses into the other. And a deleted view is the case the split creates: DeleteView can take away the view another client is on, so Sessions::adopt_deleted_views moves that mirror to the default and tells it, and effect::DeleteView does the same for the host — observed ("does this mirror still name a view the document has"), never predicted from the command variant. Testing any of it needs a bystander that can be moved: both audience tests first sat theirs on the default, where a wrongly-addressed relay folds to a no-op, and review demonstrated the vacuity by injecting ToAll twice. Two gaps remain, both filed: a view-scoped ModelCommand names no view, so a connected client's SetAxis lands on the host's view (improvise-lltt — deferred on a condition, that no shipped client emits SwitchView, while the browser already emits TransposeAxes), and Initial View no longer follows the TUI user (improvise-azhb). Follow mode is deliberately not foreclosed.
  58. -
  59. Two tests gate ViewState membership. ViewState is the portable session slice — the one thing serialized into the Snapshot a newly-connected client gets (improvise-q08). A field may live there only if it is portable (plain data from the allowlisted crates, no Rc/Arc) and snapshot-safe (a fresh client inheriting the value is correct). wizard fails both — ImportWizard is an improvise-io type, and a half-open dialog is not something to inherit — and drill_state fails both — it holds an Rc, and pending_edits are uncommitted writes belonging to whoever staged them. Both live on App under the session-local residue tag (improvise-h0b). The ViewState: Send static assertion in app.rs's tests is the compile-time proxy for portability: Rc is not Send, so a re-added shared pointer fails the build (the Deserialize derive from gotcha 25 now rejects it too, serde's rc feature being off). Send does not catch the origin-crate or snapshot-safety halves — check those by hand.
  60. -
  61. : commands settle their mode by observation, not prediction. ExecuteCommand ends every batch with ReturnToNormalUnlessMoved(ctx.mode), which drops to Normal only if the mode is still what the batch started in. There is no Effect::changes_mode() any more: it was a per-type constant, and ViewBack/ViewForward/StartImportWizard swap the mode only on some runs, so no up-front answer was right — :import opened the wizard and was snapped straight back out (improvise-toc). A new effect that changes the mode needs no annotation; just don't return the batch's starting mode from a success path.
  62. -
  63. There is no aggregate render cache — each widget folds its own stream. improvise-protocol::render holds one cache type per widget (GridCache, CategoryTreeCache, FormulaCache, ViewListCache, TileBarCache), each implementing WidgetCache with its own Update type, and a Projection is one such update addressed to one widget (improvise-rkeq). reduce_view folds only ViewState now — it no longer takes a cache, and Command::Project passes through it untouched. Two consequences. The overlap between caches is deliberate: the tile bar and the category panel both carry (category, axis) and are updated by separate projections, so neither is woken by the other's changes; do not "fix" it by sharing a variant. Bootstrap is a compaction, not a replay: Snapshot { view, seed: Vec<Projection> } seeds a new client with a batch of the same projections the steady stream carries, so there is no whole-state type that could disagree with the incremental one. The seed is the server's current state, not the head of the history — GridCache::seed() builds it, and the property that earns it is GridCache::folded(&cache.seed()) == *cache. Nothing is defined for the help overlay (its content is static and help_page is already in ViewState) or for the import wizard, which now has a shape and deliberately no projection: WizardCache is built by project::wizard_cache and drawn locally, because a half-open dialogue is addressed to precisely the session that opened it, never to every client (improvise-764 — the rendering-side twin of the argument that removed AcceptWizardModel from ModelCommand, gotcha 38). A cache without a Projection is the shape session-local residue takes when it still has to be drawn by two different front ends. It lives in improvise-protocol rather than improvise-command for the flatten_cat_tree reason: when the wasm client grows a wizard it must draw these rows, not a second derivation.
  64. -
  65. The grid's labels frame its cells, and apply enforces it — the fold is not commutative. Three consequences in improvise-protocol/src/render.rs. (a) A GridUpdate::Cells that overtakes the RowFrame/ColFrame naming its positions is dropped, because fit_to_labels truncates to the labels it has; a client must not reorder the stream, and a_cell_update_that_overtakes_its_labels_is_lost pins it. (b) A label change that renames a position blanks the cell under it: fit_to_labels preserves by POSITION, which is only right where the label did not change, so apply calls invalidate_relabelled itself rather than trusting a producer to send GridUpdate::Invalidated first. Invalidated therefore now means only "contents stale, frame unchanged". (c) every arm that touches labels or widths re-fits — col_widths is re-fitted with the columns — a new column is drawn at its label's own character count until ColWidths catches up — because a renderer zips widths with labels and a length mismatch indexes off the end. Fitting on the label arms alone is what let a late ColWidths put the mismatch straight back — a short update keeps each unheard-of column at its label's width, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holds Cells. GridCache also carries totals and page_coords (improvise-n10). totals is framed by col_frame on the same terms as the cells and widths and re-fits with them, with one asymmetry: an empty strip means "this grid has no totals" (records mode, or an empty grid) and is therefore not padded, where a non-empty one is — padding it would draw a blank total row under a records grid that has none. Invalidated blanks the totals too: they are contents, and a stale sum under fresh cells is the one number a user takes at face value. page_coords is framed by nothing (it names categories, not positions) and survives a reshape. Note the fold tests: asserting fold(prefix)+fold(rest) == fold(whole) is vacuous (it is the definition of a left fold), so the tests that earn their place are the order-sensitivity one and the compaction-equivalence one — see doctrine(fold_tests_must_test_order_not_the_axiom).
  66. -
  67. A formula target is unquoted, and pipe quoting has one home. parse_formula strips the pipes off a target exactly as it does off an Expr::Ref — the target names a _Measure item, so a target that kept its delimiters made add_formula create an item literally named |Total Revenue|: a ghost column the user's real item never matched, and Expr::Ref("Total Revenue") never resolved to. Both the TUI formula minibuffer (AddFormula) and .improv load go through that one call, so both were affected. The writer emits Formula::raw, not target, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly one home: improvise_formula::quote, owning both halves — quote_name/is_bare_name, pipe_quote (unconditional, for a .improv text cell that must not read back as a number) and unescape_pipe_body. improvise-gp re-exports it, improvise-io calls it from persistence/{format,parse}.rs, parser.rs::unquote_pipe is a shim over it, and io's own copy is deleted (improvise-i2m5). The keyword guard is canonical — user-confirmed, so do not revert it as over-caution: an item literally named SUM is written |SUM|. Bare keywords do parse, but only because agg_call needs a ( and PEG backtracking falls through to ref_expr — rule ordering, not a stated rule — and a ## Formulas line carries formula text verbatim, so a name format_md writes is read back by parse_formula: one namespace, one spelling rule. One thing the guard does not cover, and one that used to be an exception and no longer is. A section header takes its name as the whole line, totally (improvise-pczv): header_name = { pipe_quoted ~ &NEWLINE | line_name } — the pipe-quoted arm needs the lookahead, or |A|B would parse as A and silently drop B, and line_name catches everything else, so a header name can no longer fail to parse. Four rules share it (model_name, initial_view, category_section, view_section), which is why it is a rule and not four copies. Note a header is still written raw where a reference is quoted — the grammar accepts both arms, so the quoting asymmetry between a header and a reference is a writer convention, not a format one. And the two former unquoting copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it and parser.rs panicked — the surviving unescape_pipe_body is total. Two ## Category: sections naming one category are one category, concatenated by ParsedFile::push_category in pass 1 (improvise-349m) — merging in pass 2 was not idempotent, because build_category creates its own section's item-groups before its own > lines while format_md writes every item before every > line. No grammar change was needed: this is the tree-walker, not the format, and duplicate headers stay legal. And WHERE now carries IS's word boundary — both are compound-atomic with sp+ (improvise-r08u), so SUM(Rev WHEREAS x = y) is a syntax error rather than a WHERE on a category AS. The guard is sp+ and not the !bare_char lookahead the ticket proposed, because the grammar-walking generator reads the grammar through pest_meta and skips predicates, emitting WHEREa="" — a rule whose only guard is a lookahead is a rule the generator can violate. It costs one spelling: a category may no longer abut the keyword.
  68. -
  69. Dirty over-reports on purpose, and the asymmetry is the reason. Dirty::of maps an EffectKind to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant Projection, under-reporting leaves a client's cache wrong forever, with nothing in the stream to correct it. LayoutGeometry therefore includes formulas, because RemoveCategory wears it and Model::remove_category ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with Dirty::EVERYTHING and is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives in context/code/command.md, and the doc comment on Dirty::of points there rather than restating it; the reason LayoutGeometry is spelled out rather than written as EVERYTHING is a two-line comment inside the match body. It went stale once already — claiming AddCategory was CellData after it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment.
  70. -
  71. A client's command has one door, and the door takes the App. Sessions::apply_checked(app, id, Checked<'_>) is the only way a connected client's Command reaches the server — Host calls that (improvise-ahdf). Sessions::apply is not pub, delegates to apply_checked after Checked::against, and has no production caller: its call sites are tests, which is why it takes a raw &Command. ClientSession::fold and the mirror's ViewState are private, so a transport cannot reach the mirror at all. apply_switch now takes a SessionId — it is addressed to exactly one session, which is the opposite of what this gotcha used to say — moves only that session's mirror, and dirties nothing. The one thing it still asks the document is whether the view exists, because a mirror keys ViewState::cursors and is read by grid_in. The audience is conditional: an accepted switch relays nothing (the asker folded it, and no other client's view moves), a refused one is Broadcast::To(asker, …) — improvise-6c6q, where ToOthers told every client but the one that was wrong. adopt_active_view and Broadcast::ToOthers are deleted; the variants are ToAll and To(SessionId, _), the second because the deleted-view case needs a one-session audience that is not the asker. The paragraph's warning about the "what do the others hear" helper survives and is why the 6c6q test drives the asker's own ViewState — that helper is itself deleted. What survives unchanged: the model is shared and the UI is not, and Broadcast is a Vec because one command can owe two relays with different audiences. The trap this paragraph used to describe is gone with ToOthers, and the lesson it taught is not. It was copying ToOthers from apply_switch into apply_model, invisible from the bystander's side because adopt_active_view moved the asker's mirror either way — so the drift sat between the server's picture of the asker and the asker's own slice, where a test written through a "what do the others hear" helper looks away by construction. improvise-6c6q closed the mirror-image hole on the refused path. Two rules survive the mechanism: assert the Broadcast itself, since the audience is the message rather than something to infer from folded state, and seat a bystander somewhere a wrong relay can move it from — a bystander already on the relayed value cannot see a wrongly widened audience either, because the wrong relay folds to a no-op. Review demonstrated that vacuity twice on PR #48. A Vec rather than an Option because one command can owe two relays with different audiences, and an Option would have had to drop one silently. Everything else is that client's alone and never touches the App's own view slice: the model is shared, the UI is not. Folding SwitchView into the mirror alone was the bug — viewport() then read the cursor under the new view's key and the next project() built that client a grid out of the other view.
  72. -
  73. gp-tree returns data and never prints, and a fit takes two things to reproduce. Two rules with one root — the crate is a library a TUI, a wasm worker or a CLI may each host. (a) A decision the user should hear about comes back as data: Dataset::warnings carries the columns preprocess dropped for being entirely missing (improvise-d44s), and fit.rs hands back pooled (prediction, actual) pairs and a score per feature rather than tables. examples/fit_csv.rs owns every println!, and that is the only reason its output stayed byte-identical across these moves — adding an eprintln! to either module is the regression. (b) The seed is necessary and not sufficient: data::Model records seed: Option<u64> and config: Option<FitConfig>, because the same seed under a different population/generations/elite/penalty lands on a different tree (improvise-ksst). Both are Option for one reason — every FitConfig value and the seed 0 are legal, so nothing can double as "unknown". Note #[serde(default)] on an Option field is documentation, not the mechanism: serde defaults it anyway, so deleting the attribute fails no test, and a backward-compatibility test that only deletes it is not discriminating. Aim such a test at an absent field loading as the default instead of as unknown. The GA loop itself is fit::fit (improvise-0ai); breed remains one fitness-agnostic generation for callers whose fitness is not "predict this column". (c) A scoring function takes one slice of rows. Row<T> pairs a feature vector with what it predicts, so the parallel x/y lengths that zip used to truncate silently cannot be built (improvise-dkqn, doctrine(an_invariant_is_made_unrepresentable_not_validated) — the same answer prune_axis_items got, and the reason no Result entered five signatures). The pairing is made at the boundary that builds the data: preprocess walks one row index and the rows live inside Target, because a Dataset carrying rows and target separately just moves the zip up one level into every caller. The empty row set is one shape, answered once in mean_over. Changing any of this needs bit-level evidence: tests/scoring_known_answers.rs pins every path as f32 bit patterns plus a next_u64 witness that catches a changed RNG call order, because the CLI prints three or four decimals and byte-identical output alone cannot see a moved accumulation. (d) A fit is storable only when the document can name its features. preprocess invents Region=North, Cost.missing, when.year; those are encoding artefacts, not items of the document, and Expr::Ref resolves through Model::find_item_category — so a tree splitting on one emitted a well-formed formula whose every cell was Error("ref:…"), silently (improvise-8xhy). fitted_formula therefore takes the &Model and refuses, listing what it could not resolve; formula_text still spells it, being the tree's own text. The check asks the document, never the name's shape, because a measure literally named Region=North is a legal document and the two are indistinguishable as strings — which also means .missing and the date parts are covered with no second copy of preprocess's expansion rules to drift from. This is an interim (improvise-4zl7): a WHERE predicate needs a scalar coordinate predicate the formula language does not have — Filter gates a formula or an aggregate, it is not an Expr — would still leave .missing and the date parts, and must first answer what such a predicate means where context.get(cat) is None, since Formula::filter falls through to aggregate_raw and a categorical fit would silently degrade in every view that aggregates over the category. improvise-zv8t closed the categorical half of the accident, and the close cost a document. fit_measure handed preprocess an Overrides::default(), so it never said which header columns are document categories although the very next line told Features::of — and preprocess infers Numeric → Date → Categorical from the values, so a Year category with items 2024/2025 was a numeric column and a category of ISO dates a date column, both refused with Year IS 2024 spellable all along. It now forces the same header[..category_columns] slice to Forced::Categorical. Two things follow. The date decline is narrower than it read — it covers a genuine date measure, never a category misread as one. And the forcing widens the encoding: a hundred-item numeric category one-hots past MAX_FEATURES and refuses a fit that previously succeeded by never splitting on it (improvise-e8no — and not to be closed by making the forcing conditional on cardinality, which is inference by another name and makes the encoding depend on a budget rather than on what the document says). The Artefact arms stay: crate::dataset is pub and takes the caller's Overrides, so the roles are still reachable. Forcing by name while budgeting by index defeats the budget — a category and a measure may legally share a name, and no_ambiguous_column_names ran after preprocess, so the expansion the preflight exists to prevent was allocated and only then refused; as_categories now returns Result<Overrides>, so the ordering is a compile error rather than a convention. The test that missed all this is the shape to remember: improvise_eval fabricates a _Measure item per feature name, so it asserted the emitter and gp_tree::eval agree against a model that could not exist. It is kept, with its scope now stated, and the property it could not state is asserted separately against a real document.
  74. -
  75. The wire vocabulary is derived twice, by the same recipe. Command's view half is the EffectKind::ViewOnly effects targeting the portable slice (decision(improvise-cqi, command_set_derived_not_invented)); its model half, ModelCommand, is the EffectKind::Model(..) effects — 26 of them since improvise-jytm made SwitchView, ViewBack and ViewForward ViewOnly — minus ApplyAndClearDrill and AcceptWizardModel, leaving 24 variants (improvise-kmgs). SwitchView no longer needs subtracting separately: it is not a model effect any more. ViewBack/ViewForward left for the same reason as the other two exclusions — their operand is ViewState::view_back_stack, session-local residue belonging to whoever navigated. Note the obvious awk extraction reports 30: it picks up HelpPageSet, which is ViewOnly, because that is the last pub struct before #[cfg(test)] and the test module mentions ModelChange:: — brace-match the impl Effect blocks instead (doctrine(derive_then_verify)). Three things follow. A ModelCommand does not mutate: ui/reduce.rs::model_effects maps it back to the effect it came from and apply_command runs that through App::apply_effects, so effects stay the only mutation site and the Dirty is the effect's own EffectKind, never a second classification beside gotcha 35's table. Two model effects are deliberately off the wire. ApplyAndClearDrill and AcceptWizardModel were once kept on the grounds that the command is portable even where the operand is not; that was the wrong criterion (improvise-3x3u). A Command::Model is routed with no SessionId, which is right exactly when the change is addressed to no one session — and these are addressed to precisely one, never the sender: their operands are drill_state.pending_edits and the built wizard workbook, session-local residue (gotcha 30) private to whoever staged them (improvise-tom1). Unaddressed, B's ApplyAndClearDrill commits A's uncommitted typing and B's AcceptWizardModel swaps the document out of a dialogue B never opened — both demonstrated against Sessions::apply before removal. So 29 − SwitchView − these two = 26. The effects are untouched and still reached by the key path. Not permanent: multi-client drill wants the addressed form, which is where ADR 001 layer 2 goes anyway. Guarded by no_model_command_commits_another_clients_session_local_state, plus a companion asserting both effects still exist and are still model changes so the first cannot pass by naming nothing. Sessions::apply_checked routes the model half to apply_model, which relays adopt_cursor_epoch's ResetCursor and adopt_deleted_views's per-client switch; a document-replacing LoadModel bumps the generation and lands in the rebase arm. The former claim here — that ViewBack, ViewForward and LoadModel all end in App::switch_to_view so every mirror takes the accepted active view — was wrong on all three clauses after improvise-jytm and is deleted. A keymap lookup still cannot return a Command, and not for want of vocabulary: a binding names an intent resolved against CmdContext (move-cursor down) while a Command is the resolved mutation (SetSelected { row, col }) — improvise-gxi's AC 4 asks for something the split forbids.
  76. -
  77. Applied is an enum because a document swap is not a diff. Sessions::apply_checked returns Applied::Diff { dirty, broadcast }, Applied::Rebased { snapshots } or — since ADR 001 layer 1 — Applied::Rejected { reason }, and the enum is the mechanism: a host that wants the Dirty must match, and matching names the case where a Snapshot is owed. The server's mirrors self-heal — ClientSession::project reseeds on the document_generation bump — but ViewState::cursors lives on the client, keyed by the outgoing document's view names (gotcha 24), and only a fresh Snapshot replaces it. Command::Model put the three WholeWorkbook mutations on the wire (gotcha 38), so the host that must send the snapshots is no longer the host that asked. apply_model therefore calls rebase itself rather than instructing the host to, exactly as apply_switch adopts the accepted view itself. The trigger is the generation moving, not the command variant: a LoadModel that cannot read its path leaves the document alone, and reseeding on the variant would cost a client its cursors for a failed load — a_load_that_fails_is_not_a_rebase guards that arm. A rebase: bool beside dirty was rejected, because the host that forgets to call rebase is the host that forgets to read the flag. The third case is a normal outcome, not an error, and it is in the enum for the same reason the other two are: a host that wants the Dirty must match, and matching names it. ServerMsg::Rejected is the matching frame, addressed to the asker alone — nothing happened, so nobody else is owed anything — and the client keeps it as a Rejection rather than a sentence, because force needs to know which record and which kind (gotcha 43). Fixture warning. When testing a document swap, the incoming document's active view must have the same name as the outgoing one. A rename makes apply_model broadcast a SwitchView; the client folds it, and the stale cursor is then read under a key nothing has written — right by accident, with the bug invisible. other_document() renames, and a first version of this test passed because of it; a_document_shaped_differently_under_the_same_view_name() keeps the name and changes only the shape, so there is nothing to hide behind.
  78. -
  79. A batch's basis is three tiers, and only two of them can be read off the effect. App carries document_generation (bumped by install_workbook), layout_version (per ModelChange::LayoutGeometry effect) and record_versions (per record) — ADR 001's stale-basis check, and shared document state, so never on ViewState (gotcha 30). The unit is the record: record_of(key) is the coordinate tuple minus _Measure (record_row_identity), because renaming a category coordinate in wide records mode moves every measure cell in the row (gotcha 1) — a cell-level unit is violated by one legal edit, so the unit must be at least as coarse as the coarsest legal write. Both tiers are read in apply_effects off the same EffectKind the Dirty is: one classification, two readers, no second table to drift from gotcha 35's. layout_version bumps inside the loop for the reason Dirty accumulates there — an aborted batch must not claim effects it never ran (gotcha 21). record_versions cannot, because an EffectKind says that a batch writes cells, never which; so the records are observed, via a per-record XOR fingerprint of the raw data taken when the batch declares a CellData effect and diffed after it. Three consequences. A write landing the value already there bumps nothing, which is correct — no basis was invalidated. A LayoutGeometry effect that drops cells will bump their records when it shares a batch with a CellData one: an over-bump costing at most an unnecessary rejection, the same direction Dirty::of is deliberately sloppy in. And only raw data is fingerprinted — formula results live in Model::formula_cache, and a record whose derived value moved because someone edited elsewhere has not itself been written. The diff collects into a set: chaining before.keys() with after.keys() counts any rewritten record twice, a bug that survived one test run.
  80. -
  81. Storage is not a VFS, and that closes the SQLite question. The trait is whole-document read/write bytes (gotcha 2, invariant(storage_is_two_ops)) — no offsets, seeking, handles or locking — so an sqlite3_vfs, which needs xRead(offset,len)/xWrite/xTruncate/xLock, has nothing to bind to. SQLite can still sit behind it, as an in-memory database serialized to a blob (rusqlite serialize/deserialize_read_exact, proven with a probe before declining — declining on a capability that actually exists would have been the wrong decline). It is worth nothing: measured on 18,000 cells the SQLite container is 22% larger than .improv, 4.7× larger gzipped, ~160× larger at the floor (24 KB against ~150 bytes), and still rewritten whole on every save, so the one dimension SQLite normally wins — incremental page writes — is unavailable. improvise-9ix was declined on that evidence. Two things generalise past SQLite. A second serialization format is a second implementation of the grammar, obliged to track improv.pest forever; and its own round-trip test cannot detect drift from .improv, because it never compares the two — that missing cross-format equality test is what made the ticket look cheap. If a structured-query story is ever wanted it is a one-way export_sqlite beside export_csv, native-only and feature-gated: libsqlite3-sys' C build fails under this repo's nix cc-wrapper for wasm32-unknown-unknown, which would break the wasm CI gate the day it landed.
  82. -
  83. The browser client's render decisions are made in Rust, and the frames it speaks are nobody's but the protocol's. improvise-wasm-client/src/render.rs builds a RenderModel in which every question with a silent wrong answer is already answered, because the DOM half (web/app.js) is untested JavaScript and a wrong answer there shows up as a wrong pixel rather than a failing test. Three such answers. editable is a fact about the CellId variant — Record only, per ADR 002 — and the trap is the third case: None means "no projected cell", not "aggregate", so conflating them either hides a cell or offers a write the type system exists to make unconstructible. The selection is placed by reconciling the scroll offset, because GridCache is viewport-indexed while ViewState::selected is absolute. render.rs does it by adding — r + cursor.row_offset == sel_row — rather than subtracting, which is why there is no underflow path and why a selection above the window simply never matches. Omitting the offset entirely looks right until the first scroll, so the test that earns its place uses nonzero and unequal offsets: at selected=(5,1), row_offset=4, col_offset=1 it separates "reconciles" from "ignores" and from "swaps the two offsets". The category panel is viewmodel::flatten_cat_tree, never a second walk of CategoryTreeCache — that second walk was improvise-edp. The page is four string calls: Client::viewport (the handshake — the Snapshot seeds the visible cells, so one built at extent zero is an empty grid and a resize is a fan-out with nothing dirty), on_frame (one frame, one fold, arrival order — gotcha 33 holds inside a batch, so never coalesce), render_model, typed_cell.

    -

    One envelope pair, two deployments (improvise-xks6). improvise_protocol::frame owns ClientFrame { Viewport, Command, Key } and ServerFrame { Snapshot(Box<_>), Commands(Vec<_>), Rejected }; improvise-ws-server and improvise-wasm-client each pub use them and neither defines one. There used to be three pairs for two deployments — the socket's server side, a hand-kept mirror of it in the browser bundle, and a third written for the worker (improvise-tm6) before the projection architecture landed. The question that had to be answered before any of it moved was whether the worker and the socket want the same vocabulary, and the answer is yes: every difference was a date rather than a decision, and the test applied to each was whether the argument for it names the thing they differ in. None did. Viewport is deployment-independent — the extent is not in ViewState and no effect produces it, so no derivation reaches it, and a worker hosting the same Sessions owes set_extent the same answer; it is the handshake on both because a Snapshot seeds the visible cells. Key is deployment-independent too — a binding names an intent resolved against a CmdContext a thin client does not hold (gotcha 38), which says nothing about what the bytes travel over; the socket lacked it because key forwarding was never built on it (improvise-bpq2), and Host::handle now answers ClientFrame::Key with an explicit empty vec that says so, where the frame used to just fail to parse in next_client_msg. And Commands(Vec<_>) batches where a singleton cannot, because a fan-out must arrive whole and in sequence (gotcha 33) — a property of the fold, not of the transport; an in-process postMessage interleaves exactly as a socket does. The worker pair was not merely different but incoherent by omission: written before ADR 001, it could carry Command::Model { basis, .. } and had no Rejected, so a client speaking it composed a claim that could never be answered. That incoherence — not the difference — is the tell for staleness.

    -

    The mirror is gone, and so are the two tests that held it honest; that deletion was the ticket's measure of success. the_socket_framing_matches_the_server round-tripped hand-listed fixtures through the other crate's types to catch a rename; every_server_variant_is_one_this_crate_mirrors matched exhaustively to catch a variant added on the server and never mirrored. With one definition both are type identity: a rename is a compile error at every call site in both crates, and the un-mirrored variant is not a constructible state. That is stronger than either test, and it is the shape to insist on — if a consolidation leaves one of its compatibility tests alive, the consolidation is not finished. What survives is the_retired_spellings_do_not_parse in frame.rs, aimed at the one thing still possible (the retired Bootstrap/Command spellings returning as serde aliases, which would be two homes again wearing one type), and it keeps the injected-fault discipline this paragraph earned the hard way: assert the mutated input is still well-formed before feeding it in, and assert the error kind — serde's unknown variant — not merely that an error occurred, because a rewrite that also breaks the JSON makes the check pass against an enum that really does have the variant. End-to-end compatibility is now tests/browser_round_trip.rs, which hands the wasm client's own serialized frame to a real Host::handle; improvise-ws-server stays a dev-dependency of the browser bundle for exactly that.

    -

    What the harness does and does not reach is gotcha 48's subject — web/app.js is no longer unrun (harness/tests/shell.spec.js executes it under Chromium), improvise-hwpi is closed, and the quiet-failure and never-reads-a-key claims that used to stand here went with it. What remains unexercised is named there: the grouped and multi-level DOM, the CSS and the ResizeObserver extent arithmetic. What a careful read did establish about the last of these: the observer cannot feed back into itself (#grid is a flex child with overflow: auto, so replaceChildren cannot resize it), and ROW_PX/COL_PX under-estimate, so the worst case is a row of over-fetch. What is verified end to end is the Rust half: tests/browser_round_trip.rs drives improvise-1ey's stated scope against a real Host over a real App touching the client only through the four string calls. One deployment note that is not a bug: examples/demo.improv leaves Customer/Date on the None axis, so every cell is an aggregate and nothing is editable — correct behaviour, bad demo. Pick a model with no regular category on None.

  84. -
  85. A batch carries its basis, and the server had to publish it first. Command::Model { basis, cmd } is a struct variant so the basis cannot be forgotten, and Sessions::apply_model checks it before applying anything — a stale batch is never applied, not even partially (improvise-xwi6/fkfg, ADR 001 layer 1). Four things to know. Basis has no Default and no public constructor: a zero-valued basis is not "no claim", it is the claim that the document is fresh, which in a new App is true — so a default would be a forgeable bypass rather than an absence. BasisCache::claim is the only door a client has and it returns None until the server has published one. Note what that does and does not buy: Basis derives Deserialize because it must cross the wire, and its fields are public, so a hand-rolled client can state any tiers it likes. This is an anti-staleness coordination check, not a security boundary — RecordUnstated closes omission, not invention, and nothing here tries to close invention, because a client that forges a current basis is indistinguishable from one genuinely caught up. "The only constructor" is a statement about clients written against this crate, which is still worth having: it makes the honest path the easy one. Which records are checked is the server's decision — reduce.rs::records_named, an exhaustive match with one or-pattern for the variants that name no record, so a new record-writing variant fails the build. Reading the list off the client's claim would be a force by omission: the record tier skipped, with no flag to remove and nothing in the message saying so. A claim that omits a record the command writes gets Rejection::RecordUnstated, a case of its own because "you lost a race" and "you were never in one" are different things to tell a user. Rejection keeps structural and data staleness apart, which is the whole reason ADR 002 put a maintained RecordHash index in DataStore: RecordGone means nothing resolves and there is nothing to force (force never creates structure), RecordMoved means it resolves at another version and force is one keystroke — which is also why the wasm client stores the Rejection itself rather than collapsing it into status_msg, since a string cannot carry which record or which kind. And the ADR omitted the publication. It said a batch carries the basis it was composed against and never said how a client learns one; nothing in the projection stream carried any of the three tiers, so the check as written was satisfiable only by forging. Projection::Basis + BasisCache close that — the server publishes the two document tiers plus the counters for the records this client's grid names, diffed against what that client was last told and seeded in the bootstrap — and it is emitted after the grid updates in a batch, because the counters describe the cells those updates carry: a basis arriving first would let a client claim a version for a value it had not been shown, the mirror image of gotcha 33. That ordering was documented as load-bearing in three places and held up only by the order of two statements until a_published_basis_follows_the_grid_updates_it_describes pinned it (proved by injecting the swap) — the same job a_cell_update_that_overtakes_its_labels_is_lost does for the mirror-image rule. Two things are unchecked and deliberately so: Command::SwitchView carries no basis (it sits outside Command::Model; the workbook refuses a view it does not have, so it cannot corrupt, but it is a hole in the rule), and apply_command/reduce_full do not check at all — the check is a property of the session door, not of the mutation path, which is right because a host holding the App is the truth rather than a claimant. That second one was a door rather than an exemption, and needed a type to stop being one (improvise-ondj): both took a wire Command and their Command::Model { cmd, .. } arm discarded the basis with .., while ui/reduce.rs's own module doc pointed every future host — the browser worker included — at exactly those two names. The struct variant closed omission from the message; it did nothing to close ignoring on arrival, which is the half a struct variant is usually reached for. So both now take Checked<'_>, a private-field newtype over a &Command whose only constructor is Checked::against(&App, &Command) -> Result<_, Rejection> — also the only caller of stale_basis. Sessions::apply_checked receives the proof and Checked::against is answered once, before the dispatch, so apply_switch/apply_model receive the proof rather than each remembering to ask: an invariant made unrepresentable rather than validated, the same answer prune_axis_items and gp-tree's Row<T> got. Visibility was the weaker option and was rejected for a concrete reason — pub(crate) closes only the out-of-crate path, and reduce_full has no production caller anywhere in the workspace, so it would have become dead code. The regression guard is therefore the signature: the test that reproduced the bug no longer compiles, and what remains asserts the constructor refuses and that refusing leaves the document untouched. One residual hole, documented on the type and filed: a Checked does not name which App it was checked against, so a multi-document host could in principle check one and apply to another. That is also why ui::reduce::caught_up(&App, cmd) is not a bypass: it takes &App, so nothing reachable from a wire can call it. Since improvise-955n the frame, not the command, is what is answered — and only two of the three tiers hoist. CheckedBatch::against runs before anything in a ClientFrame::Commands is applied. document_generation and layout_version describe the document, so answering them once for the frame is exactly right, and a first version hoisted all three — which was a lost update: the record tier asks "is the client's picture of this record current", and which records are at stake is a property of the command (records_named). Skipping it for later commands let [SetCell(North), SetCell(South)] overwrite a South somebody else had moved, silently, with the frame reported accepted, and invisible to the other two tiers because a SetCell is CellData and moves neither. So every mutation answers for its own records against the frame's basis — the one the first basis-carrying command stated, never each command's own field, or a later command could claim a fresher picture than the frame was composed under. The one exemption is stated over a property rather than a list of commands: a record that neither resolves in the document nor is claimed by the basis is one the frame is about to create ([AddItem(East), SetCell(East)], which BasisCache::claim cannot possibly claim for), and only that combination is exempt; a record that does not resolve but is claimed is still RecordGone. That the Basis inside a later Command::Model is ignored rather than compared is an honest interim: clause 1 says the frame states one basis, and moving the field from Command::Model to ClientFrame::Commands would make that unrepresentable-otherwise instead of a convention — a protocol change that belongs with clause 4. Two things worth keeping from the rejected design: arity is not a property of a variant (a future Command::RunScript is one variant performing unboundedly many mutations), so no count over the vocabulary can enforce "one mutation per frame"; and the transaction boundary is not the frame, because one command may already be a chain, which is why clause 4's mechanism belongs at App::apply_effects.
  86. -
  87. The TUI grid draws from its cache, and since improvise-anr9 the cache frames it too. GridWidget holds no &Model and no &View: cell text, aggregate-ness (CellId::Aggregate, ADR 002), column widths, the page bar, the totals strip and now the frame all come off GridCache. The TUI still builds that cache over the whole grid (offsets zero), so a cache position is an absolute position — the browser's Viewport is a real window, the TUI's is not, and the two clients therefore use it differently; column totals must be projected rather than client-derived, because the sum runs over every row including the ones a viewport scrolled past. The frame is an AxisFrame per axis — cats (the categories, outermost first) plus entries: Vec<FrameEntry> interleaving Group(FrameGroup) headers with Item(Vec<String>) label tuples, exactly as GridLayout::row_items interleaves them. That replaced a Vec<String> built by GridLayout::row_label, which slash-joined the tuple — the exact rendering two_row_categories_produce_cross_product_labels says must not appear — so the browser drew Food/Alice where the TUI drew two header columns, with no groups and no collapse indicators. It is one type and not a labels plus a levels beside it: two answers to "what are the labels" is the disease (improvise-edp), and this is the second cure, which cost a type change rather than an addition. Three things to know before touching it. leaf_labels() is not "the labels" — it is the innermost label, what a one-line summary calls a row by; a draw loop wants header_labels(first_rendered), groups() and shares_ancestry(sel), each of which is one pass, because item/label/shows_level/group_of scan entries from the start and asking them per position is quadratic in the frame and grows with the TUI's absolute scroll offset (found three times on anr9; the per-position forms survive for one-off lookups and say so). The width rule has one home, improvise_protocol::header_level_widths — widest label per level, plus a gap, clamped — reached by AxisFrame::header_widths and by ui::viewport::compute_row_header_width, which borrows level tuples off a GridLayout rather than building a frame on the keypress path. A cats change re-identifies every position: a cell's identity is cats zipped with its tuple, so invalidate_relabelled blanks the whole axis when the categories move, not merely the positions whose tuples differ, and blanks the totals on a recategorization of either axis — a column total is a sum over the rows, so re-identifying the rows leaves a number answering coordinates the grid no longer has (gotcha 33(b): the rule lives in apply, not in a producer's memory). What is still not on the cache is record_col, and that is correct: it keys pending drill edits and is session-local (gotcha 1). body_from(idx) takes an argument only because the two front ends scroll differently — a browser's frame is already windowed and passes 0, the TUI's covers the whole grid and passes its row offset — not because the rule differs. group_of answers group membership: the nearest preceding header unless an AxisEntry::GroupEnd closed the run first (improvise-gd83, landed in PR #77). It previously answered "nearest preceding header" unconditionally, which gave an ungrouped item the preceding group. GroupEnd draws nothing and exists only so membership is not inherited. Still open beside it: group_runs coalesces by name where FrameGroup carries a cat, so two runs under different headers merge (improvise-h0iv), and AxisFrame::first is serde(default), so a frame that loses the field claims it leads the axis (improvise-1wl0). And project::grid_of evaluates every cell per frame where the widget evaluated only the visible ones — a widening of an existing whole-grid cost. Since improvise-jytm grid_in may additionally derive a GridLayout for a session on a view the host is not on; the host's layout is reused where the names agree, which is the common case and the only one that can carry a drill snapshot.
  88. -
  89. One session loop, two transports — and the browser saves itself. Host is App + Sessions + handle(id, ClientFrame) -> Vec<Addressed> and lives in improvise_command::ui::host, not in improvise-ws-server, which re-exports it. It moved for improvise-9x6: the worker hosts the same authoritative App over a MessagePort and cannot depend on tokio, and a second copy would have been exactly the mirror improvise-xks6 had just deleted for the frames — a drifting Applied/Broadcast match is invisible until a client sees it. registry stayed on the server, because a worker hosts one document. What the worker adds is only what a MessagePort differs in: one client by construction (the Addressed filter is honoured anyway, against a future SharedWorker), and a handshake with no close — ClientFrame::Viewport must still come first, but a pre-handshake frame is dropped rather than closing a connection that does not exist.

    -

    Saving is a frame, and dirtiness is read off the classification the effect already carries. effect::Save is ViewOnly (gotcha 3) — correctly, since it writes this host's file_path through this host's Storage — so the derivation that builds ModelCommand from the model effects cannot name it, and for a while that meant no client on any transport could ask its host to save. ClientFrame::Save closes it (improvise-368h): a request carrying no path, because a client cannot see the host's storage, answered ServerFrame::Rejected(NoDocumentPath | SaveFailed) to the asker alone and silently on success — nothing moved, so no widget is dirty. It is a frame and not a Command for one concrete reason: as a Command it put a runtime-fallible operation inside a batch ClientFrame::Commands promises is all-or-nothing, so [Save, SetCell] reported a failed write and then applied the edit. It also fails the test every Command passes — it mutates no shared state and no client's slice, so reduce_view has nothing to fold and Checked has no basis to answer for. And MarkDirty is gone. It was a separate effect the command layer composed beside each mutation — reasonable on the key path, unreachable from anywhere else, because improvise-kmgs derived ModelCommand from the effects one at a time and so put SetCell on the wire without its companion (improvise-gv9r): a derivation correct per element and wrong for a set, whose symptom was that "save if dirty" never fired. App::apply_effects now raises the flag for any EffectKind::Model(_), all three ModelChange levels included, which makes omitting it unrepresentable rather than validated. It over-approximates in the same direction layout_version does — a LoadModel that cannot read its path leaves the flag raised, costing one redundant write — and Save clears it in loop order, so a batch that edits and then saves ends clean. The worker's persist trigger is therefore the flag, not a table of command variants: an observation rather than a second description of which commands change the document, for the reason cursor_epoch beats matching on CycleAxis (gotcha 24) and document_generation beats matching on LoadModel (gotcha 39). It is also strictly tighter — a refused batch raises nothing, so the "was anything rejected" guard went with it. A browser still never sends ClientFrame::Save, and that is the answer rather than a gap: there is no file the user owns and no Ctrl-S semantics, the document is the OPFS entry, so the worker persists on its own behalf through the one existing Save effect. The cost that remains: Storage is whole-document (gotcha 41), so OPFS rewrites the file per accepted model command.

    -

    The async seam does not survive being boxed. OpfsFs::open/attach/refresh/commit_deletes are on the struct, not on Storage. App::storage is a Box<dyn Storage> and a trait object can name none of the four, so handing the backend to the App puts the async seam permanently out of reach; the host must hold the concrete backend and give the App a Storage, which means sharing one. That is impl<T: Storage + ?Sized> Storage for Rc<T>, and it lives in improvise-io rather than in each worker (improvise-hmws): impl Storage for Rc<OpfsFs> is two foreign types in any other crate, so improvise-9x6 wrote a forwarding newtype that every future worker would have written again. Rc and not Arc because the one caller is a Web Worker and wasm32 has no other thread. The flat root is no longer load-bearing: OpfsFs::list joins dir like the other two backends, so storage_contract takes dir as a parameter and the three agree at every directory rather than only at Path::new("") where joining is the identity (improvise-vhcz). What the index holds is documents, not entries, and the split between its three writers is the rule: attach indexes nothing (what it creates is zero bytes), Storage::write indexes the name and un-queues any pending delete of it, and refresh rebuilds from OPFS skipping zero-byte entries — counting an entry it cannot measure as a document, since the one cause of that is another tab holding the exclusive handle (improvise-vvl3).

    -

    What no test here can see has shrunk, and precisely. harness/ (improvise-vvl3/xf1) runs improvise_io::storage::contract::storage_contract against a real OpfsFs inside a real dedicated Web Worker under Chromium and Firefox, and the browser job in CI can fail the build — so OpfsFs is no longer compile-checked-and-never-run, and two bugs fell out of its first execution. That is why storage_contract stopped being a #[cfg(test)] helper and became a public function returning Result: a browser must run that sequence, not a second transcription of it, and a failure has to travel out of a worker as a string. Still unseen: Safari (Playwright's WebKit has no OPFS, so xf1's criterion is met for two engines of three; opfs.spec.js skips on a runtime probe and an unexpected skip on any other engine is a failure). bindings.rs is NOT in that list, and said it was for months. shell.spec.js's "the standalone deployment" loads the app with no ?ws=, which spawns the worker, which imports the glue, which calls bindings.rs::start and OpfsFs::open — and the spec then asserts a grid draws, so the whole chain has run under Chromium in CI since improvise-vvl3. The claim survived in five places (this line, the File Inventory, two kb.pl facts and improvise-bck's grooming note) because each was written before the harness landed and none was re-read after; the spec's own comment says the chain plainly. It is the reason to distrust a "never run" written in prose: nothing checks it, and kb.pl's CI gate checks consistency rather than truth. What was already verified stays: tests/post_message.rs drives a real Worker over a real App on nothing but the JSON a MessagePort carries. Build note unchanged: the worker bundle needs --enable-nontrapping-float-to-int as well as --enable-bulk-memory, because it carries the whole App and so chrono's date arithmetic.

  90. -
  91. The page picks a peer, and the demo was built against an assertion. web/app.js holds two transports and no protocol knowledge: ?ws= selects improvise-ws-server, its absence spawns new Worker('./worker.js?doc=&seed=', { type: "module" }) (improvise-djm). type: "module" is mandatory — worker.js imports the bundle — and there is no MessageChannel: a dedicated worker's own postMessage/onmessage is the port, so the ticket's channel wording was a date rather than a decision. The websocket path is kept, because the ws-server is how a TUI-hosted document is shared and nothing else in the browser can reach it; the cost is contained because the two differ only in where strings come from — handshake, fold, repaint and keyboard are all above the seam — and the presence of the parameter is the whole selection mechanism, a second knob being a second thing to get wrong. #status composes two facts, the transport's and the document's, because a single source let a repaint silently erase a connection error. A worker that rejects in start() fires no error on the main thread — worker.js catches its own — so the guard is a first-frame watchdog, deliberately not per-transport: "the peer never answered the handshake" is the one failure that means the same thing over a socket and over a port, and a check aimed at each peer's own failure modes would have missed exactly this one.

    -

    The demo is a model built to satisfy a check, not an axis flipped and hoped for. examples/demo.improv leaves Customer/Date on None, so every cell is an Aggregate and nothing is editable — and the naive fix does not work either, since Product × Region by Date_Month × Measure still aggregates. web/demo.improv puts exactly one category on each visible axis (Product row, _Measure column, Quarter page, nothing regular on None) and carries Profit = Revenue - Cost, so the payoff is on one screen with no keystroke. Settability is asked of grid_of as a CellId variant, never re-derived from the axes — a second derivation could agree with itself while disagreeing with render.rs::editable, which is the whole point of ADR 002 — and the assertion was proved discriminating by pointing it at examples/demo.improv, where it names the aggregate at (0,0). One category per axis is a property of the demo, not of the format: when this was written GridCache slash-joined a multi-level tuple, so a second category on an axis put that known gap on the front page — improvise-anr9 has since closed it (gotcha 44), so what remains is the demo's own simplicity rather than a rendering limit. web/*.improv is un-ignored in .gitignore (*.improv is ignored repo-wide) and the CI assemble step copies it: leaving it out is an empty grid, not a 404 the page could report. One wart it now puts on the front page: a formula cell is a Record, so it is offered as editable and a write to it reverts on recompute — pre-existing and matching the TUI, newly visible.

  92. -
  93. Keys become commands in the browser, and the server must not know the keymap. The user's ruling on improvise-bpq2, following from "the model is shared, the UI is not" — and it buys two clients different bindings over one document. What made it urgent is not latency. a_key_resolved_on_the_host_moves_state_no_client_can_hear shows nothing in the projection stream carries the host's own ViewState, so host-side resolution diverges; and Host::handle answers ClientFrame::Key with an explicit empty vec, so an upstream key is a black hole, not a slow path — every KeyOutcome::Upstream names a binding that does nothing at all in a browser. That test still holds and was not flipped: bpq2 closes the divergence by avoiding it. The method is the ticket's — enumerate the bindings, not the commands (doctrine(binding_dump_diff)) — and where a client cannot compose the Command, enrich the projection rather than moving resolution back.

    -

    Three things that cost. GridExtent on GridCache — the whole grid, of which the labels are one window — because a viewport-indexed cache cannot tell "the last visible row" from "the last row". It is deliberately not framed by the labels (fit_to_labels leaves it alone) and its batch position is not load-bearing the way RowFrame-before-Cells is, since it addresses no position; the producer reads GridLayout::row_count(), and reading it off the projected ranges is invisible in the TUI (which builds at offsets zero, gotcha 44) and is exactly the browser bug. The window is the client's own fact, recorded by Client::set_viewport from what it announced — not derivable, since the row frame's own length is the window clipped to the grid and so differs precisely at the bottom edge where the scroll arithmetic matters. Client::viewport (associated, no recording) still exists and is the one that quietly turns cursor keys off; web/app.js does call set_viewport. ClientFrame::Commands(Vec<Command>) replaces the singleton, because one cursor key is SetSelected plus the offsets viewport_effects returns and a host that folds the selection but not the offset projects the wrong window. It is answered whole, then applied whole (improvise-955n): CheckedBatch::against checks the frame before anything runs, so there is no longer a moment at which part of a frame has landed and the rest has been refused, and Applied::Rejected is unreachable through Host::commands — still matched, because a host that wants the Dirty must name every case (gotcha 39). The contract is four clauses and clause 4 is not delivered: one basis per frame, checked once, applied in order with each command seeing the last one's state, and all-or-nothing. The fourth is open because App::apply_effects aborts without undoing (gotcha 21 — an abort is not a rollback), so [RemoveItem("North"), SetCell(the North record)] lands the removal, resolves the write to nothing, and reports the frame accepted having applied half of itself — partial and silent. It is unreachable from any client in this workspace, the only multi-command producer being keys::commit; reachability is a reason not to rush a fix and not a reason not to notice one — the lost update CheckedBatch fixed had the same reachability argument and none of the documentation, which is how it shipped.

    -

    Two corrections to the ticket's own gap list, both worth more than the code. Panel cursor movement was never a gap: improvise-edp had already made viewmodel::flatten_cat_tree the one home both ends call, so it is the shared derivation, not a second one — worth re-checking the "known gaps" in other old tickets against what has landed since. And the "unmodified characters only" rule was mis-scoped, not wrong: it was about KeyPattern::AnyChar, which is bound only in the text-entry modes, so Ctrl-d in Normal has an exact binding and mirroring it is a faithful subset. Each resolver mirrors its Cmd line for line, including where siblings disagree — Move and MoveTileCursor emit even when they clamp, MovePanelCursor returns nothing when the cursor does not move. Tidying that into one rule would make the client a second derivation, which is what tests/lockstep.rs exists to rule out. What still needs the host is a named list in keys.rs and in the_normal_mode_keys_that_still_need_the_host_are_these, so closing one has to come there and say so.

  94. -
  95. The browser JavaScript is argued and now partly executed, and the two rules it can break are both about when. web/app.js and web/worker.js were syntax-checked, .d.ts-checked for call arity and read — never run — and every bug in them was found by reading. harness/tests/shell.spec.js is the first execution of either: it runs app.js under Chromium against a mocked peer, and it exists because PR #21 declined to add regression tests for its own close-listener fix on the ground that there was nowhere to put them. That decline is only honest because the assertions live there now, and a change to web/*.js belongs in that spec directory rather than in a second ad-hoc surface beside it. Playwright rather than a headless-DOM shim because what goes wrong in these files is a real Worker, a real WebSocket, real OPFS and real event ordering, none of which jsdom has. Two classes of bug still follow from when, both found by review. An async message listener does not serialize. worker.js's handle() awaits server.flush(), so the event loop may start the next message while an earlier one is suspended, interleaving two on_message/flush pairs and letting a later frame's replies overtake an earlier frame's — which gotcha 33 answers by dropping the overtaken update, not by replaying it. Every frame is therefore chained onto one tail promise and the listener is deliberately not async; that also turns "frames that arrived before start() finished" into an ordinary case of the ordering rule instead of a drain loop, which had the same race twice over because server was assigned before the loop ran. A watchdog must be disarmed, not pre-empted. The handshake watchdog exists for one condition — the peer never answered — and every loud failure (worker error/messageerror, socket error) has already written something more precise; the timer then fired 8s later and replaced it, so setShellFailure clears it. Its neighbour, the socket's close listener overwriting a precise error with "disconnected", is fixed (improvise-taxx, PR #21) — and the ticket's own proposed mechanism was wrong: readyState cannot discriminate, an opened flag is the only thing that can. PR #21's other half is the one that keeps getting lost: it routes both close arms through setShellFailure, which disarms the watchdog, so a genuine mid-session disconnect is not overwritten eight seconds later. A bug report said it was, measured it honestly against a pre-#21 tree, and then "confirmed" it survived #21 by simulating the opened flag and not the routing — a simulated fix that is not the fix you named reproduces a bug the real code does not have, and reads exactly like a confirmation. The ticket was filed and closed invalid; the property is now asserted by a socket that opened and then dropped reads as disconnected, and stays that way, which runs past the 8s window, rather than argued in a comment that was wrong twice.

    -

    What the harness still cannot see, and why the argued-not-tested discipline stays: the grouped and multi-level DOM, the CSS, and the ResizeObserver extent arithmetic. What a careful read established about the last of these — the observer cannot feed back into itself (#grid is a flex child with overflow: auto, so replaceChildren cannot resize it) and ROW_PX/COL_PX under-estimate, so the worst case is a row of over-fetch — is still a read and not a test.

    -

    A third thing no test here can see, which is why the docs must be read as code. Three comments in one range said "not built yet" about work that had landed in that same range — README.md's browser section, app.js's keyboard note, and Host::handle's Key arm — each because a follow-up commit updated one copy of an argument and not its siblings. When a fact is stated in more than one place, changing it is a grep, not an edit.

    -

    An evidence heading may be module-qualified, and must be when its leaf is not unique. scripts/check-comment-runs.py --check-evidence takes its Rust names from cargo test --workspace -- --list minus the --ignored twin, as full module paths, and refuses an ambiguous leaf naming every candidate rather than warning. Six leaf names collide in this workspace. Note neither half suffices alone: rename one of a colliding pair and the leaf is unique again and resolves silently to the survivor, so qualification is the fix and the refusal is only what forces someone to write it (improvise-rm4s). The residual, filed: --list does not name the binary, so two crates sharing a module path are indistinguishable (improvise-sxsf) — measured latent (1444 names, zero collisions) and the proposed per-package fix declined twice over: 18× the cost (45.2s against 2.4s, and --check-evidence needs the --ignored twin, so 22 cargo invocations), and a package is not a binary — improvise alone has five integration test binaries beside its lib, and an integration test's top-level fn lists with no module path at all (60 of 1444 names are bare), so -p improvise prefixes all six identically. Per-target is the only shape that would close it.

  96. -
  97. A websocket handshake is exempt from the same-origin policy, and that is a server-side problem. There is no CORS preflight, so any page a user visits can open a socket to improvise-ws-server from that user's own allowed address and read and rewrite every document it holds — cross-site websocket hijacking (improvise-kc7n). No network control can see it: an IP allow-list, a private network and loopback binding all answer "may this client reach the endpoint", and the honest answer is yes; only Origin says what is driving the client. Loopback is the least protected case, not the safest — no proxy in front of it, and loopback is not a boundary a browser enforces. src/origin.rs is the whole decision and transport::admitted its one caller; it refuses the upgrade with a 403 rather than accepting a socket and ignoring it, and serve takes the policy so a host cannot forget to apply it. Three arms are decided rather than defaulted: loopback allowed (the shipped local recipe serves its page from http://localhost:8080, so the one case worth optimising for must need no flag), absent allowed (no browser omits Origin, so absent is a non-browser client an address check already answers — and refusing it breaks tests/socket.rs, the repo's own instrument), and two refused (HeaderMap::get is first-wins, so a proxy that appends rather than sets leaves two, and answering from the first lets the second be anything). The deployed Pages origin is deliberately not hard-coded: it can only reach a plain-ws:// server through a TLS proxy anyway, so one flag is an honest cost on an already multi-step path where a baked-in default would be a permanent hole for every other deployment. The residual hole is stated rather than guarded — a proxy that strips Origin turns the check off, which is why the proxy's own allow-list stays in README.md as defence in depth. None of this is authentication: it answers what page is driving a client, never who is connecting, and improvise-kpnr is still open. It correctly does not move to improvise-command beside Host (gotcha 45) — a MessagePort has no handshake and no headers, and a worker and its page are the same origin by construction, so this is genuinely transport-only and not a mirror waiting to drift.
  98. -
-
-

#File Inventory

-

Line counts are a dated snapshot, not a maintained fact: measured 2026-09-21, and 26 of 61 had drifted past 10% before that refresh — several by a third. Treat them as "is this file big", never as a figure to cite; re-derive with wc -l. Note the ws-server/ and improvise-worker-server/ entries use a shorthand prefix that no path-walking refresh resolves, so they drift silently and must be checked by hand. Test counts are informational — run cargo test --workspace for live numbers. Files under 100 lines and render-only widgets omitted.

-

#improvise-core (crates/improvise-core/src/)

-
model/types.rs         2352 / 74t   Model, formula eval (fixed-point), CRUD
-model/cell.rs          1234 / 43t   CellKey (sorted), CellValue, DataStore (interned + index),
-                                    live_records (insertion order — gotcha 18)
-model/record.rs         326 /  8t   RecordHash / CellId surrogates: the opaque identities a
-                                    thin client names a cell by (ADR 002)
-model/category.rs       316 /  9t   Category, Item, Group, CategoryKind
-model/symbol.rs          79 /  3t   Symbol interning
-view/layout.rs         1251 / 30t   GridLayout, drill, records mode, records_columns
-view/types.rs           711 / 36t   View config (axes, pages, hidden, collapsed, format)
-view/axis.rs            205 /  5t   Axis enum + label/color/next methods, AxisColor
-workbook.rs             345 / 17t   Workbook: Model + cross-view ops, item facade
-format.rs               229 / 29t   format_f64, parse_number_format (display only)
-

#improvise-formula (crates/improvise-formula/src/)

-
parser.rs              1087 / 67t   pest grammar + tokenizer → Formula AST
-quote.rs                192 /  7t   quote_name / is_bare_name / pipe_quote / unescape_pipe_body:
-                                    the one home, both halves (improvise-i2m5). improvise-gp
-                                    re-exports it; improvise-io calls it from
-                                    persistence/{format,parse}.rs
-formula.pest            103         PEG grammar for formula text
-ast.rs                   77         Expr, BinOp, AggFunc, Formula, Filter
-

#improvise-io (crates/improvise-io/src/)

-
storage.rs               388 / 13t   Storage trait (read/write/list/delete), PhysicalFs, MemoryFs,
-                                     `impl Storage for Rc<T>` (improvise-hmws), and
-                                     contract::storage_contract — public, not cfg(test), because
-                                     a browser must run that sequence (harness/)
-opfs.rs                  367 /  0t   OpfsFs: Storage over the browser's Origin Private File
-                                     System. wasm32 only. Run by harness/, not by cargo test
-persistence/improv.pest 153         PEG grammar — single source of truth
-persistence/mod.rs      403 / 10t   document lifecycle: save/load/gzip/legacy-JSON over Storage;
-                                     facade re-exporting format_md/parse_md/export_csv*
-persistence/format.rs   385 / 11t   format_md: Workbook → .improv text
-persistence/parse.rs   1519 / 43t   parse_md: pest tree-walk → Workbook (+ grammar generator)
-persistence/export.rs   219 /  4t   export_csv / render_csv. Builds its layout with
-                                    with_frozen_records, NOT new — `new` skips prune_empty
-                                    (improvise-iot6)
-persistence/roundtrip_tests.rs
-                        965 / 45t   format_md ↔ parse_md characterization (tests only)
-import/wizard.rs       1714 / 55t   ImportPipeline + ImportWizard
-import/analyzer.rs      403 / 13t   Field kind detection (Category/Measure/Time/Skip);
-                                    analyze_records is first-seen order, not HashSet (gotcha 18)
-import/csv_parser.rs    495 / 20t   CSV parsing, multi-file merge
-

#improvise-protocol (crates/improvise-protocol/src/)

-
lib.rs                   561 /  1t  ViewState, ViewCursor, ViewFrame, AppMode (17), Panel,
-                                    MinibufferConfig, MinibufColor, HELP_PAGE_COUNT
-reduce.rs                569 /  9t  Command + ModelCommand (the wire format), HelpPage, reduce_view
-render.rs               2008 / 35t  CellDisplay, WidgetCache trait + one cache per widget
-                                    (GridCache/CategoryTreeCache/FormulaCache/ViewListCache/
-                                    TileBarCache), AxisFrame/FrameEntry/FrameGroup and
-                                    header_level_widths (the grid frame, gotcha 44),
-                                    GridCache::seed (the bootstrap compaction), Projection,
-                                    bootstrap-only Snapshot
-keymap.rs                957 /  0t  KeyPattern, Binding, Keymap, ModeKey (17), TextEntry,
-                                    PanelKeymap, Keymaps::default_keymaps/prefix_keymap — the
-                                    shared table both front ends look up. Tests live with
-                                    dispatch in improvise-command/src/command/keymap.rs
-viewmodel.rs             168 /  5t  flatten_cat_tree — derivations between a cache and a
-                                    renderer, called by both front ends (improvise-edp)
-key.rs                   101 /  3t  KeyCode, KeyModifiers, KeyEvent (moved from command/)
-frame.rs                 330 /  3t  ClientFrame/ServerFrame — the ONE envelope pair, both
-                                    deployments (improvise-xks6). Nothing else defines one.
-                                    ClientFrame::Save is a request, not a Command (gotcha 45)
-basis.rs                 332 /  5t  Basis (the three tiers), Rejection, BasisCache — ADR 001
-                                    layer 1. Basis has no Default and no public constructor
-help.rs                  925 /  7t  HelpPage content and Hint { keys: Vec<(KeyCode, KeyModifiers)>,
-                                    command, caption }. `compact` drops a chord by its MODIFIERS
-                                    (anything beyond shift), never by parsing a label it just
-                                    formatted (improvise-thpe) — the guard is the signature
-resolve.rs              2477 / 46t  the browser's key resolvers: a BrowserKey to the Commands it
-                                    composes, mirroring each Cmd line for line INCLUDING where
-                                    siblings disagree (gotcha 47). The largest file in the crate
-client.rs                255 /  5t  ClientState: ViewState + one cache per widget, and the fold
-

#gp-tree / improvise-gp (crates/gp-tree/, crates/improvise-gp/)

-
gp-tree/src/lib.rs          669 /  8t  Tree, Spec, eval, simplify, render, breed, Population,
-                                       SplitMix64 (vendored seeded Rng; seed() keeps the seed so
-                                       a fit can be shown and recorded, pinned to the published
-                                       reference vectors)
-gp-tree/src/data.rs         938 / 19t  raw string rows -> f32 feature vectors: per-column type
-                                       inference, one-hot, mean-imputation + col.missing flag,
-                                       ColumnSpec/encode_row, Dataset (incl. warnings),
-                                       Model = trees + plan + seed + FitConfig
-gp-tree/src/fit.rs          970 / 25t  the search: FitConfig (Default = the CLI's numbers), fit()
-                                       -> simplified champion, cv_regress/cv_classify,
-                                       permutation_importance, folds/standardize/mse.
-                                       Returns data; never prints
-gp-tree/examples/fit_csv.rs 585        CLI over the above: flags, demo dataset, all printing
-gp-tree/tests/scoring_known_answers.rs
-                            285 /  6t  f32 bit patterns + a next_u64 witness: the evidence any
-                                       change to the scoring paths needs (gotcha 37c)
-improvise-gp/src/lib.rs     844 / 21t  formula_text / fitted_formula: a Tree as IF(...) formula
-                                       text, validated back through parse_formula. A threshold is
-                                       the SHORTEST decimal that reads back as the same f64 (not
-                                       the exact expansion), so a row on a split boundary routes
-                                       the same in f64 as in f32 eval; non-finite values panic
-improvise-gp/src/dataset.rs 632 / 15t  a View's records -> the feature matrix a fit consumes —
-                                       the improvise-side counterpart of preprocess. Records mode
-                                       is already a feature matrix (gotcha 1)
-improvise-gp/src/fit.rs    1426 / 23t  one fit end to end: a View's records in, an improvise
-                                       Formula and the evidence for believing it out. Not in a
-                                       command, because none of it is terminal-specific
-

#Command layer (crates/improvise-command/src/command/)

-
cmd/core.rs             333 /  2t   Cmd trait, CmdContext, CmdRegistry, parse helpers
-cmd/registry.rs         822 /  3t   default_registry() — all registrations
-cmd/navigation.rs       502 / 12t   Move, EnterAdvance, Page*
-cmd/cell.rs             194 /  6t   ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
-cmd/commit.rs           739 / 11t   CommitAndAdvance, CommitBuffer(CommitKind), CommitExport,
-                                    CommitSave
-cmd/effect_cmds.rs      459 /  5t   effect_cmd! macro, 25+ simple wrappers
-cmd/fit.rs              280 /  7t   fit-measure: evolve a tree over the active view and store it
-                                    as an ordinary `## Formulas` line — nothing GP-specific is
-                                    left in the document afterwards
-cmd/grid.rs             540 /  9t   ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
-cmd/mode.rs             414 / 11t   EnterMode, Quit, EditOrDrill, EnterTileSelect
-cmd/panel.rs            688 / 17t   Panel toggle/cycle/cursor, formula/category/view panels
-cmd/search.rs           206 /  4t   SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
-cmd/text_buffer.rs      382 / 14t   AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
-cmd/tile.rs             138 /  5t   MoveTileCursor, TileAxisOp
-cmd/wizard.rs           244 /  6t   HandleWizardKey — per-step import-wizard key tables
-keymap.rs               917 / 35t   KeymapSet, Keymap::dispatch/dispatch_transient, effects_of,
-                                    SetTransientPrefix — what a thin client cannot have. The
-                                    table itself is improvise-protocol/src/keymap.rs
-parse.rs                232 / 19t   Script/command-line parser (prefix syntax)
-

#Semantic UI state (crates/improvise-command/src/ui/)

-
ui/effect.rs           2731 / 64t   Effect trait, 50+ effect types
-ui/app.rs              3407 / 92t   App, ModelState, ViewState + dispatch methods, Panel, handle_key, dispatch_key, document_generation, autosave; apply_effects raises `dirty` off EffectKind::Model(_) (gotcha 45)
-ui/project.rs          3708 / 55t   Dirty, Viewport, grid_of/category_rows/formula_texts/
-                                    view_summaries/tile_entries, grid_updates (the diff),
-                                    ClientSession + Sessions (connect/apply/rebase/project_all)
-ui/reduce.rs           1411 / 18t   Checked / CheckedBatch / apply_command / model_effects /
-                                    reduce_full / reduce_key — one door from a Command or a
-                                    keypress to the authoritative App
-ui/host.rs             1493 / 24t   Host: one App + one Sessions; handle, commands, project_all,
-                                    rebase. Lives here, not in ws-server (gotcha 45)
-ui/cat_tree.rs          184 /  8t   Category tree flattener for panel
-ui/viewport.rs          124         compute_col_widths / compute_row_header_width / visible_cols
-                                    (pure geometry; the width rule itself is the protocol's)
-

#TUI rendering, draw, main (src/ui/, src/draw.rs, src/main.rs)

-
ui/grid.rs              985 / 14t   GridWidget (ratatui): no &Model, no &View — the frame comes
-                                    off GridCache (gotcha 44). Its module doc is the best-written
-                                    statement of that rule in the repo
-ui/help.rs              603         5-page help overlay (render only)
-ui/import_wizard_ui.rs  947 /  9t   Import wizard rendering
-draw.rs                 599 /  5t   TUI event loop, frame composition
-main.rs                 393         CLI entry (clap): open, import, cmd, script
-# other ui/*.rs are small panel renderers — skip unless changing layout/style
-tests/grid_frame_agreement.rs
-                        422 /  4t   the two front ends draw the same grid frame (improvise-anr9)
-tests/records_wide_smoke.rs   67 / 1t
-tests/shipped_documents_load.rs 42 / 1t
-tests/web_demo_model.rs       80 / 2t   every visible cell in web/demo.improv is a Record
-

#Browser host and client (crates/improvise-ws-server/src/, crates/improvise-wasm-client/src/, web/)

-
ws-server/registry.rs    595 / 14t  Registry of Hosts keyed by DocumentId; ClientId {document,
-                                    session} — a SessionId is unique only within its document.
-                                    A document is never evicted while the server runs (ADR 001)
-ws-server/host.rs         23 /  0t  a re-export shim only. The session loop is
-                                    improvise-command/src/ui/host.rs (gotcha 45)
-ws-server/origin.rs      132 /  6t  OriginPolicy: which origins may open a socket. Loopback by
-                                    default, --allow-origin widens, absent allowed, two refused
-                                    (improvise-kc7n). The only decision transport::admitted makes
-ws-server/transport.rs   324 /  4t  tokio + tungstenite plumbing, plus the Origin check at the
-                                    handshake — a refused upgrade, not an ignored socket
-ws-server/main.rs        190 /  5t  CLI entry: serve a file on a port; --bind, --allow-origin
-ws-server/tests/socket.rs 284 / 2t  the one test that uses a real socket: handshake, JSON
-                                    framing, SessionId routing, which document a URL asked for
-wasm-client/lib.rs       333 /  7t  Client: the wasm-bindgen surface (init, on_message, on_frame,
-                                    viewport/set_viewport, render_model, set_cell_at, typed_cell)
-wasm-client/keys.rs      187 /  5t  BrowserKey ONLY — a KeyboardEvent flattened to an improvise
-                                    key. It does NOT resolve: `resolve` and KeyOutcome live in
-                                    improvise-protocol/src/resolve.rs, shared by both front ends
-wasm-client/render.rs    888 / 12t  RenderModel: editable / selected / cat tree, decided here
-wasm-client/socket.rs    289 /  7t  the fold (fold_frame/handle_frame), the two compositions
-                                    and typed() text→CellValue. Defines no enums
-# state.rs is GONE — ClientState moved to improvise-protocol/src/client.rs
-wasm-client/tests/browser_round_trip.rs  384 / 5t  improvise-1ey's scope against a real Host
-wasm-client/tests/lockstep.rs           1126 / 23t host and client agree on a keypress
-web/app.js               496         string calls only, no protocol knowledge, no framework.
-                                     Run under Chromium by harness/tests/shell.spec.js — see
-                                     gotcha 48 for what that does and does not reach
-web/worker.js             64         three string calls, no protocol knowledge. RUN by
-                                     shell.spec.js's no-?ws path, with bindings.rs (gotcha 45)
-web/index.html, style.css            the page; legibility only
-web/build.sh              38         wasm-pack build → web/pkg (gitignored). Needs
-                                     wasm-opt = ["-O", "--enable-bulk-memory"] or the bundled
-                                     wasm-opt rejects LLVM's memory.copy
-web/assemble.sh           120         assembles the deployable site (copies web/*.improv)
-web/demo.improv           57         the document the site ships and seeds from. Every cell a
-                                     Record — asserted (gotcha 46)
-

#Browser worker host (crates/improvise-worker-server/)

-
improvise-worker-server/src/worker.rs           738 / 13t  Worker: one port, one client; handshake, Addressed
-                                        filter, persist trigger (model_state.dirty). Every decision, every test
-improvise-worker-server/src/vfs.rs              311 /  6t  documents/open: seeding is a Storage::write
-improvise-worker-server/src/bindings.rs         158 /  0t  wasm32 shim: OpfsFs awaits + postMessage. No Rust test
-                                        reaches it, but harness/tests/shell.spec.js DOES: the
-                                        no-?ws path runs start()/OpfsFs::open under Chromium.
-                                        Called NEVER RUN here for months: see gotcha 45
-improvise-worker-server/src/lib.rs              114 /  1t  the module doc that states exactly what is and is not
-                                        covered — the one correct copy of that fact
-improvise-worker-server/tests/post_message.rs   381 /  7t  main-thread traffic, strings only
-

#Browser harness (harness/) — not a workspace member

-
harness/playwright.config.js     50   shell.spec.js runs on CHROMIUM ONLY (engine-independent
-                                      logic; routeWebSocket is slower and flakier across three).
-                                      opfs.spec.js runs chromium + firefox + webkit, and webkit
-                                      provides NO OPFS at all, so it skips on a runtime probe
-harness/tests/shell.spec.js     165   the first execution of web/app.js. A change to web/*.js
-                                      belongs here (gotcha 48)
-harness/tests/opfs.spec.js       79   drives the OPFS contract page and reads back its result
-harness/opfs-contract/src/lib.rs 361  runs improvise_io::storage::contract::storage_contract
-                                      against a real OpfsFs inside a real Web Worker
-harness/www/opfs/{index.html,worker.js}  the page and worker that host it
-harness/build.sh                 35   wasm-pack build for the contract crate
-# gated by the `browser` job in .github/workflows/ci.yml, which can fail the build
-

#Examples

-
examples/gen-grammar.rs             Grammar-walking random .improv generator (pest_meta)
-examples/pretty-print.rs            Parse stdin, print formatted .improv
-

#Context docs

-
.claude/skills/code-review/         Review skill: checks a change against the doctrine in these docs + kb.pl
-scripts/check-comment-runs.py       The two-line comment cap AND --check-evidence. The flag is
-                                    ADDITIVE since improvise-ijql — every invocation that can
-                                    exit 0 answers the cap — guarded by a subprocess self-test,
-                                    the only level a dispatch bug is visible at. Reasoning:
-                                    context/code/tooling.md §2
-scripts/check-ci-graph.py           pages-deploy must `needs` every job in ci.yml. Carries its
-                                    own --self-test; run in the `test` job. README states the
-                                    rule and deliberately lists no jobs (improvise-x7cq)
-scripts/assemble-stamp.sh           The cache-bust stamp gate web/assemble.sh calls, extracted
-                                    so its refusals can be exercised without a web/pkg
-scripts/assemble-stamp-test.sh      Its check_the_checker, run first in the `build` CI job
-context/code/tooling.md             The repository's own checkers: why an evidence heading's
-                                    names come from cargo rather than from the source, and
-                                    why a leaf name is not an identity
-context/test-evidence/tooling.md    Evidence for the gates in scripts/ and for the tests that
-                                    hold CI's shape
-scripts/nix-seed-flake-inputs.sh    Seeds locked flake inputs over git so `nix develop` works in cloud sessions
-prolog-sessions/kb.pl               Executable knowledge base: invariants, doctrine, decisions (CI-checked)
-context/adr/003-live-backed-models.md A model may be backed by a live query source —
-                                    improvise as a DB client. Accepted, NOT a priority.
-context/adr/002-naming-a-cell.md     How a client names a cell: an opaque surrogate on
-                                    CellDisplay, whose absence means the cell is an
-                                    aggregate. Records why _Index cannot serve.
-context/adr/001-concurrent-edits.md  Concurrent edits between clients: record-level atomicity,
-                                    optimistic basis check per batch, advisory reservations.
-                                    Records why a causal clock was rejected.
-context/cloud-agents.md             Cloud-session environment: tracker sync, bd pitfalls, what is blocked
-context/design-principles.md        Architectural principles & testing doctrine
-context/plan.md                     Show HN launch plan
-context/repo-map.md                 This file
-docs/design-notes.md                Product vision & non-goals
-
-

#Wasm

-

The browser bundle is improvise-core, improvise-formula, improvise-io, improvise-protocol, improvise-command, improvise-wasm-client and improvise-worker-server. All seven cargo check --target wasm32-unknown-unknown clean with no feature flags (improvise-ywd), gated by the wasm job in .github/workflows/ci.yml. That job gates eight crates, not seven: improvise-gp was added (improvise-z5eg) explicitly not as a bundle member but as a candidate, because staying wasm-clean is free now and expensive to retrofit — so the bundle is seven and the gate is eight, and neither number is the other one's typo. A feature that adds a dependency to a bundle crate must be checked with cargo tree --edges normal — the default output includes dev edges, so a plain cargo tree -p improvise-protocol --target wasm32-unknown-unknown reports a dev-only serde_json as present and reads exactly like a leak. Check per-crate too, because the wasm CI job invokes each crate separately and feature unification cannot reach across that (improvise-26dm added a test-support feature on improvise-protocol on those terms; it is off in every shipped build).

-

improvise-ws-server is the host and is native only — tokio, tungstenite and Box<dyn Storage> — so improvise-wasm-client cannot depend on it in anger. It no longer needs to: both ends pub use improvise_protocol::frame and neither defines a pair (gotcha 42). The dev-dependency remains, for tests/browser_round_trip.rs, which hands the client's own serialized frame to a real Host::handle. The root improvise crate is the terminal binary and does not build for wasm — it depends on crossterm; that is correct, not a gap.

-

cargo check passing is not the same as running. Two known runtime hazards it cannot see: Instant::now() panics on wasm (hence web-time in improvise-command), and any std::fs call compiles but fails in a browser — see improvise-fuj for the two effects that still bypass Storage.

-

#CLI

-
improvise [model.improv]                          # open TUI (default)
-improvise import data.csv [--no-wizard] [-o out]  # import CSV/JSON
-improvise cmd 'add-cat Region' -f model.improv    # headless command(s)
-improvise script setup.txt -f model.improv        # run script file
-

Import flags: --category, --measure, --time, --skip, --extract, --axis, --formula, --name.

-
-

#Testing — the short version

-

Full guidance lives in context/design-principles.md §7. Quick reminders:

-
    -
  • Two numbers, not one — the ambiguity between them is what let the old "suite runs in <2s" drift: no single test binary over 2 seconds, and the workspace wall clock is the sum. Measured at 63a5c13: 1464 tests over 35 binaries (1 ignored), longest single binary 0.6–1.0s across two runs on one machine — the spread being its own argument for reading your own run. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job). Re-measure rather than re-assert — cargo test --workspace prints a finished in per binary, and the portable signal is the ratio, not the seconds: one binary an order of magnitude above the run's median. improvise-io's lib was that outlier at 1.42s on three machines and is not any more — improvise-8shu found the cost was a pest_meta re-parse per generated case, not the walk it was blamed on, and a OnceLock took it to 0.21s. The full argument is context/design-principles.md §7.
  • -
  • Commands: build a CmdContext, call execute, assert on returned effects. No terminal needed.
  • -
  • Property tests (proptest, default 256 cases) cover invariants: CellKey sort, axis consistency, save/load roundtrip, parse(format(parse(generate()))) stability.
  • -
  • Bug-fix workflow: write a failing test before the fix (regression guard). Record the bug in context/test-evidence/, keyed by test name — not in the test's doc comment, which the two-line comment cap forbids and scripts/check-comment-runs.py enforces. A heading may be module-qualified and must be when its leaf is not unique.
  • -
  • Coverage target ~80% line/branch on logic code; skip ratatui render paths.
  • -
-
-

#Key dependencies

-

ratatui 0.30, crossterm 0.29, clap 4.6 (derive), serde/serde_json, indexmap 2, anyhow, chrono 0.4, pest + pest_derive, flate2 (gzip), csv, enum_dispatch. Dev: proptest, tempfile, pest_meta.

-
-
- - diff --git a/site/docs/style.css b/site/docs/style.css deleted file mode 100644 index 4e8af207..00000000 --- a/site/docs/style.css +++ /dev/null @@ -1,60 +0,0 @@ -:root{--bg:#14161a;--panel:#1b1e24;--fg:#d7dae0;--dim:#8b93a1;--accent:#8ec07c;--rule:#2b2f37;--mono:ui-monospace,"SFMono-Regular","Menlo","Consolas",monospace} -*{box-sizing:border-box} -body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.65 system-ui,-apple-system,"Segoe UI",sans-serif} -a{color:var(--accent)} -.shell{display:flex;align-items:flex-start;max-width:1400px;margin:0 auto} -nav.side{position:sticky;top:0;flex:0 0 17rem;max-height:100vh;overflow-y:auto;padding:1.5rem 1rem 3rem;border-right:1px solid var(--rule);font-size:.9rem} -nav.side .brand{font-family:var(--mono);font-size:1.15rem;color:var(--accent);text-decoration:none;font-weight:600} -nav.side .navtoggle{display:none} -nav.side .navsummary{display:none;cursor:pointer;margin:.9rem 0 .2rem;padding:.4rem .55rem;border:1px solid var(--rule);border-radius:4px;font-family:var(--mono);font-size:.85rem;color:var(--dim)} -nav.side .navsummary::before{content:"\2630 " / ""} -nav.side .navtoggle:checked~.navsummary{border-color:var(--accent);color:var(--fg)} -nav.side h3{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin:1.6rem 0 .4rem} -nav.side ul{list-style:none;margin:0;padding:0} -nav.side li{margin:.15rem 0} -nav.side a{display:block;padding:.2rem .45rem;border-radius:4px;color:var(--fg);text-decoration:none} -nav.side a:hover{background:var(--panel)} -nav.side a.here{background:var(--panel);color:var(--accent);font-weight:600} -main{flex:1 1 auto;min-width:0;max-width:60rem;padding:2.5rem 2rem 6rem} -h1,h2,h3,h4,h5{font-family:var(--mono);font-weight:600;letter-spacing:-.01em;line-height:1.3;overflow-wrap:anywhere} -h1{font-size:2rem;margin:0 0 1.2rem;color:var(--accent)} -h2{font-size:1.25rem;margin:2.6rem 0 .6rem;padding-top:1.2rem;border-top:1px solid var(--rule)} -h3{font-size:1.02rem;margin:1.8rem 0 .4rem;color:#c8cdd6} -h4,h5{font-size:.95rem;margin:1.3rem 0 .3rem;color:var(--dim)} -.anchor{float:left;margin-left:-1.1em;padding-right:.35em;color:var(--rule);text-decoration:none;opacity:0} -h2:hover .anchor,h3:hover .anchor,h4:hover .anchor,h5:hover .anchor{opacity:1} -p{max-width:74ch} -li{max-width:74ch} -code{font-family:var(--mono);font-size:.88em;background:var(--panel);padding:.1em .3em;border-radius:3px;overflow-wrap:anywhere} -a code{background:#22262e} -pre{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:1rem;overflow-x:auto} -pre code{background:none;padding:0;font-size:.84rem;line-height:1.5;overflow-wrap:normal} -blockquote{margin:1rem 0;padding:.5rem .9rem;border-left:3px solid var(--accent);background:var(--panel)} -blockquote p{margin:.4rem 0} -table{border-collapse:collapse;margin:1rem 0;font-size:.92rem;display:block;overflow-x:auto} -th,td{border:1px solid var(--rule);padding:.4rem .6rem;text-align:left;vertical-align:top} -th{background:var(--panel)} -hr{border:0;border-top:1px solid var(--rule);margin:2rem 0} -.tid{font-family:var(--mono);font-size:.86em;white-space:nowrap} -.tid-unknown{color:var(--dim);border-bottom:1px dotted var(--rule)} -.toc{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:.8rem 1.1rem;margin:0 0 2rem;font-size:.92rem} -.toc summary{cursor:pointer;color:var(--dim);font-family:var(--mono)} -.toc ul{list-style:none;margin:.6rem 0 0;padding:0;columns:2;column-gap:2rem} -.toc li{break-inside:avoid;margin:.12rem 0;overflow-wrap:anywhere} -.toc li.l3{padding-left:1rem;font-size:.92em} -.toc a{text-decoration:none;overflow-wrap:anywhere} -.toc a:hover{text-decoration:underline} -.jump{margin:0 0 2rem;font-size:.85rem;font-family:var(--mono)} -.jump a{display:inline-block;min-width:2.1rem;text-align:center;padding:.12rem .3rem;margin:.1rem;border:1px solid var(--rule);border-radius:4px;text-decoration:none} -.jump a:hover{border-color:var(--accent)} -.lede{color:var(--dim);max-width:70ch} -.src{font-size:.85rem;color:var(--dim);font-family:var(--mono);margin:-.6rem 0 1.8rem} -.cards{display:grid;gap:1rem;grid-template-columns:repeat(auto-fill,minmax(19rem,1fr));margin:1rem 0 2rem} -.card{background:var(--panel);border:1px solid var(--rule);border-radius:6px;padding:.9rem 1.1rem} -.card a{font-family:var(--mono);text-decoration:none} -.card p{color:var(--dim);font-size:.88rem;margin:.3rem 0 0} -.tickets{width:100%} -.tickets td:first-child{white-space:nowrap;font-family:var(--mono);font-size:.86rem} -.tickets td:last-child{overflow-wrap:anywhere} -.st{font-size:.78rem;color:var(--dim);text-transform:uppercase;letter-spacing:.05em} -@media(max-width:900px){.toc ul{max-height:45vh;overflow-y:auto}nav.side .navtoggle{display:block;position:absolute;width:1px;height:1px;margin:-1px;clip-path:inset(50%);overflow:hidden}nav.side .navtoggle:focus-visible~.navsummary{outline:2px solid var(--accent);outline-offset:2px}nav.side .navsummary{display:block}nav.side .navlinks{display:none}nav.side .navtoggle:checked~.navlinks{display:block}.shell{display:block}nav.side{position:static;flex:none;max-height:none;border-right:0;border-bottom:1px solid var(--rule)}main{padding:1.5rem 1rem 4rem}.toc ul{columns:1}} \ No newline at end of file diff --git a/site/docs/test-evidence/clients.html b/site/docs/test-evidence/clients.html deleted file mode 100644 index c4c6620c..00000000 --- a/site/docs/test-evidence/clients.html +++ /dev/null @@ -1,521 +0,0 @@ - - - - - -The clients — improvise docs - - - -
- -
-

Source: context/test-evidence/clients.md

On this page

Test evidence: browser client, hosts, harness

-

What each of these tests proves, what fault was injected to prove it can fail, and — where it applies — the bug it was written against. Keyed by exact test name; a renamed test orphans its entry, which scripts/check-comment-runs.py --check-evidence reports.

-

The design argument each of these belongs to is in context/code/clients.md.

-
-

#improvise-worker-server/src/worker.rs

-

#an_edit_is_persisted_without_the_client_asking_to_save

-

The persistence half of improvise-9x6's acceptance criterion 2, and the reason the worker saves on its own behalf: there is no ModelCommand::Save for a client to send, because effect::Save is ViewOnly and the wire vocabulary is derived from the model effects. So an edit must reach storage with the client having asked for nothing but the edit. The test writes one cell over the wire and reopens the document through the same VFS.

-

#a_partly_refused_batch_does_not_leave_disk_and_memory_disagreeing

-

improvise-955n's second-order effect, which nobody had written down until it was found while declining the ticket: on_frame skipped persist() when any frame in the answer was Rejected, so a batch that did apply its earlier command before being refused left the document moved in memory and not written back — disk and worker disagreeing, silently.

-

Two changes close it independently, which is why this is worth a test of its own rather than trusting either: the batch no longer applies partially at all (improvise-955n), and the persist trigger is now the document's own dirtiness rather than "was anything rejected" (improvise-gv9r), so a batch that did change something would be written whatever else the answer contained.

-

The fixture is a refused frame with a command behind the refusal, and the assertion is that the axes on disk and the axes in memory agree. Under the settled contract the refusal comes from the frame's basis — answered once, on the first command that carries one — so that is where the staleness is injected; a later command carries no answer of its own.

-

#an_explicit_save_is_not_retried_by_the_automatic_write

-

Found by Copilot on PR #26. A failed write leaves model_state.dirty set — deliberately, so the next frame retries — and the automatic trigger runs after every frame. A save frame therefore attempted the same write twice: extra I/O on a permanent failure, and on a transient one something worse, because the answer is composed from the first attempt, so the client is told SaveFailed about a document that is now on disk.

-

The fix is a per-frame question rather than a per-document one, and that is the right shape precisely because the automatic write exists for edits nobody asked to save; one that was asked for has already been answered.

-

The fixture is a Storage backend whose writes always fail and that counts them. Counting is the point: "it was refused" is true whether the write was attempted once or twice, so a test asserting only the refusal would pass against the bug. Failing is the point too: a successful save clears dirty, so the redundant second attempt never happens and the bug is invisible from the happy path.

-

#a_key_resolved_on_the_host_moves_state_no_client_can_hear

-

Why the Key arm answers nothing, demonstrated rather than asserted in prose.

-

A worker can call reduce_key — it holds the authoritative App, which is the thing a socket server lacks. What neither has is a way to tell a client that the host's own ViewState moved: a client's slice advances only by folding Commands, and resolving a keypress produces none. So the two slices diverge, and ClientSession::viewport goes on reading the mirror's cursor, which means the grid does not even follow. That is improvise-bpq2's real cost, and it is a ticket rather than plumbing. The test asserts all three: the authoritative cursor moved, the mirror did not, and nothing reduce_key emits would let a client catch up.

-
-

#improvise-worker-server/src/vfs.rs

-

#an_entry_that_holds_no_document_is_seeded_over

-

The first browser run of the deployed page failed here, and no host test could see it: attach(create: true) leaves a zero-byte entry behind, documents listed it, and open therefore took the seed as already applied and read an empty file. The precondition is asserted before the act, because a test that only opens cannot tell "the seed landed" from "there was nothing in the way". The rule it pins is the one gotcha 45 states for the index: what is indexed is a document, and an entry nobody has written bytes to is not one.

-

This entry exists because the test cites this file and --check-evidence now requires that (improvise-lyyc); it was written from the test, so read the test first if the two ever disagree.

-
-

#improvise-ws-server

-

#distinct_stems_both_open

-

doctrine(check_the_checker) for colliding_stems_are_fatal: inject the fault that test rules out — distinct stems — and both documents must open. Otherwise an open_all that failed on every second file would pass the collision test.

-

#a_bystander_on_the_same_document_is_projected_the_edit

-

doctrine(check_the_checker) for an_edit_is_projected_only_to_the_edited_documents_clients: put the bystander on the same document and the assertion must flip. Without it, an isolation test that passed because nobody is ever projected anything looks identical.

-

#the_other_document_would_also_have_a_cursor_to_invalidate

-

doctrine(check_the_checker) for a_broadcast_does_not_cross_a_document_boundary. The fault to rule out is the other document being incapable of producing the broadcast at all — then a server-wide relay would be silent over there for the wrong reason and the isolation test would pass without proving isolation. So the checker drives the same TransposeAxes against beta and asserts it does produce a ResetCursor there; beta's silence in the isolation test is the routing and nothing else.

-

Both tests moved from Command::SwitchView to ModelCommand::TransposeAxes with improvise-jytm: a view switch is now addressed to one session and relays nothing, so it can no longer carry an isolation test. ResetCursor is the surviving Broadcast::ToAll.

-

#a_load_that_fails_rebases_nobody

-

doctrine(check_the_checker) for a_rebase_stops_at_the_document_boundary: inject a load that cannot happen. The generation does not move, so nobody is rebased — which is also what a routing bug that sent Snapshot to nobody would look like, so the rebase test needs this one beside it to mean anything.

-

#a_reopened_document_would_have_lost_the_edit_and_the_basis

-

doctrine(check_the_checker) for the_last_session_leaving_does_not_close_the_document: inject the eviction — a document freshly opened from the same fixture, which is exactly what closing on last-disconnect would hand the next client. Both assertions in the lifecycle test fail against it, so neither passes for a reason other than the document having stayed put.

-

#a_rebase_stops_at_the_document_boundary

-

Fixture note, and it is gotcha 39's warning: the swapped-in document keeps the same active view name and changes only its shape. A rename would make apply_model broadcast a SwitchView, the client would fold it, and the stale cursor would then be read under a key nothing has written — right by accident, with the bug invisible.

-

#a_client_on_a_socket_is_seeded_and_gets_its_edit_back

-

(crates/improvise-ws-server/tests/socket.rs, and its neighbours in that file.) Everything the socket test asserts is already asserted in host.rs without a socket, and deliberately so: its subject is the shell, not the decisions. It earns its place by covering what the unit tests structurally cannot — the handshake rule, JSON framing in both directions, the routing from a SessionId back to the connection it came from, and the one fact that lives only in a URL: which document the connection asked for. If it ever starts being the place a behaviour is pinned, that behaviour belongs in host.rs or registry.rs.

-

Two disciplines inside it are load-bearing rather than stylistic. The write is composed from the surrogate the server projected (ADR 002) — a CellKey there would be the test knowing something a browser does not — and the basis is composed from what the server published, because a basis the test built itself would be a client claiming to have seen something it was never sent.

-
-

#improvise-wasm-client/src/keys.rs

-

#text_entry_modes (a fixture, not a test)

-

Not a test — a fixture, and its limitation is the evidence. The comment here used to claim a new mode "shows up as a missing line rather than as untested behaviour". It does not: this is a hand-written list and nothing checks it against AppMode, so a new text-entry mode is simply absent and silently uncovered — which is what happened to save_prompt (improvise-lae) until it was added by hand. There is a second hand-written copy in the command crate's keymap::tests::text_entry_modes_with_exits. Deriving both from one place is filed, not done.

-

#a_modified_character_now_types_because_the_fallback_is_the_shared_one

-

The old classifier sent a modified character upstream in a text-entry mode, because the host's AnyChar fallback matches under any modifier and a hand-written rule could only safely be a subset of it. Reading the same table removes the question: Ctrl-x falls through to AnyChar here exactly as it does on the host.

-

#search_typing_writes_the_query_and_not_a_buffer

-

Search typing resolves on the client for the first time, and only because the keymap is shared: SearchMode binds append-char ["search"], and the name "search" is ViewState::search_query rather than an entry in buffers (improvise-gdl). The old classifier sent every search key upstream into an empty vec. The negative half of the test matters too: Esc stays upstream, because exit-search-mode owns the query and is the host's.

-

#the_command_names_that_still_need_the_host_are_these

-

The command names this client cannot execute, one by one, so that closing one of these gaps has to come here and say so. This replaced a list of keys, and that is the whole gain of sharing the keymap: a rebinding no longer invalidates the list, and a new binding for an already-executable command works in a browser with no change to this crate. Each entry is driven through the key that reaches it, because a name that is not bound anywhere would otherwise sit in the list forever looking like a gap.

-

#a_prompt_this_client_cannot_commit_is_never_entered

-

The refusal list above is only half the property. Upstream says the key produced no Command; it does not say the client stayed put, and the bug being guarded is precisely entering a room with no exit.

-

Found by Copilot on PR #27. Without strands_the_user, Ctrl-W resolved enter-mode save-prompt locally — ChangeMode(SavePrompt), a perfectly good Command — and the user then typed a path into a prompt whose Enter goes nowhere. Ctrl-E did the same into ExportPrompt and had done since before that branch.

-

The negative arm has two halves, and both are needed. Half one: a mode that is not refused is still entered — the half that goes through strands_the_user, so the only thing that can fail when the predicate degenerates to "refuse everything". ? is honest here where : was not: Help has no commit at all, so nothing a user types into it can be swallowed, and Esc closes it locally. (Its page keys are upstream, which makes page navigation inert — but inert navigation loses no work, which is the line the predicate draws.) Half two: the property is about finishing, not entering, so something has to press Enter and observe. The first version of this test asserted only that : entered its mode, and : is itself a dead room — so that arm was certifying the very behaviour it existed to rule out.

-

#the_command_line_cannot_be_committed_by_this_client

-

The evidence behind listing CommandMode in strands_the_user, rather than the assertion that it is listed. : opens locally and typing appends locally, but Enter is [execute-command, clear-buffer] and step has no execute-command arm — so the whole sequence fails to resolve, Host::handle drops the frame, and every ex-command behind : is inert. That is what makes refusing the mode cost no working surface. When improvise-e24v gives this client execute-command, this is the test that should start failing, and the CommandMode arm of strands_the_user is what it points at.

-

#a_commit_in_the_last_cell_exits_editing_instead_of_advancing

-

At the bottom-right corner EnterAdvance returns AbortChain, so the re-entry is skipped and the commit leaves the user in normal mode. That abort is a fact about the host's effect chain and it has to be mirrored, or the browser would sit in an editor on a cell the terminal has left. The fixture is a one-by-one grid, so the only cell is the corner.

-

#a_move_that_goes_nowhere_follows_the_command_it_mirrors

-

Which widgets say nothing about a move that goes nowhere is not a rule of keys.rs — it is whatever the Cmd being mirrored does, and the three differ: Move emits even when it clamps, MovePanelCursor returns nothing when the cursor does not move, MoveTileCursor emits regardless. The grid arm carries its own check-the-checker inline (a j that does move), so "emits when clamped" cannot pass against a fixture that is simply inert.

-

#i_on_an_aggregate_declines_out_loud

-

i on an aggregate must not go upstream: Host::handle answers a Key frame with an empty vec, so that would be a keypress the user sees do nothing at all (doctrine(an_upstream_key_is_a_black_hole)).

-

#enter_commits_the_cell_advances_and_re_enters_editing

-

Enter in Editing is [commit-cell-edit editing, clear-buffer edit], and every part of that sequence shows up in what the client composes — including the trailing clear that empties the buffer EnterEditAtCursor has just seeded. That ordering looks wrong and is exactly what the TUI does; mirroring it line for line is the contract.

-
-

#improvise-wasm-client — lib, socket, state, render

-

#a_client_with_no_basis_composes_nothing

-

The fourth None, and the one ADR 001 adds: a client that has not been told the document's basis has nothing truthful to say about what it composed against, so it composes nothing. That is the property that makes the basis check unforgeable from a browser — BasisCache::claim is the only constructor, and it withholds.

-

The other three None shapes — an aggregate, id: None, and a position outside the grid — are pinned against the same composition in socket.rs's typing_into_a_cell_that_is_not_a_record_composes_nothing. They were asserted twice, once against each of two compositions, until improvise-r4lo left only one.

-

#a_refused_batch_is_kept_as_a_rejection_and_not_only_as_a_sentence

-

A refused batch must reach the person who composed it, and it must reach them as more than a sentence. ADR 001 makes rejection a normal outcome, and the obligation that comes with it is that the client keeps the typing and can re-compose. Force needs the Rejection itself — RecordGone cannot be forced at all where RecordMoved can — so collapsing it into status_msg would throw away the input to the very feature rejection exists to enable. Both halves are asserted, because setting the string alone is the plausible half-fix, and the revision bump is asserted too: a renderer polls the revision, and a rejection it cannot see is a rejection it cannot draw.

-

#every_rejection_message_says_what_it_refused

-

Found by the PR #12 Copilot review: DocumentReplaced read "the document was replaced — reloading", and nothing in fold_frame reloads anything. A message that names an action the code does not take is worse than a vague one, because the user waits for it.

-

Renamed and re-scoped by improvise-8uj8, which is the more interesting half. The test was called every_rejection_message_says_the_edit_did_not_land and its own doc said the fact "is asserted over all of them rather than one at a time" — while it drove a hand-written list of five, written before PR #26 added NoDocumentPath and SaveFailed. So two variants were silently exempted from a rule the test called universal, which is worse than no test: the claim was false about its own subject.

-

The re-scope is that the claim, not the messages, was wrong. A save failure did not fail to apply anything, so "the edit did not land" is the wrong sentence for it. Rejection::refused_an_edit (exhaustive) splits the two classes and Rejection::all (a cyclic walk, the same shape AppMode::all uses) drives them, so a new variant fails the build rather than slipping out of the list.

-

Fault injected: wording SaveFailed as "hmm" fails the test — the arm the old five-item list could not have seen. Adding a Rejection variant fails the build at basis.rs's refused_an_edit and after.

-

#a_typed_string_composes_the_frame_that_carries_it

-

Was a_typed_string_is_classified_as_the_host_classifies_it, a test that stated an obligation in its name and did not discharge it — it asserted three outcomes by hand rather than comparing against the host's function, so the two copies of the rule could drift and stay green. improvise-j7v6 removed the duplication rather than the drift: commit_regular_cell_value now calls improvise_protocol::typed, which the browser's compose path already used.

-

Per doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished) the test could not survive as a comparison — type identity replaced its claim, and a hand-written comparison beside the shared function would be the copy coming back. It is re-scoped to what this crate still owes, which nothing else covers: the frame a typed string composes — basis, cell and command.

-

The classification's own obligation moved to tests/typed_string_agreement.rs, where it is checked against the thing it could really disagree with: what .improv calls a number (gotcha 34).

-

#a_cell_projection_that_overtakes_its_labels_is_lost

-

Gotcha 33, carried across this crate's dispatch. The protocol crate pins the drop inside GridCache::apply; what is this crate's is that nothing between the socket and that cache buffers, sorts or retries — so the same early Cells is still lost after routing. The second half of the test feeds the same three updates in frame-first order and requires them to land, so "everything is dropped" cannot pass it.

-

#bootstrapping_from_a_compaction_lands_where_the_stream_lands

-

doctrine(fold_tests_must_test_order_not_the_axiom): a client that bootstraps from a compaction of the host's state must end up where a client that folded the whole stream is. The seed is the host's current state expressed as projections, not the head of the history.

-

#the_selection_is_placed_by_subtracting_the_scroll_offset

-

(render.rs.) The conversion that looks right until the first scroll: the grid is viewport-indexed, the cursor is not. The offsets in the fixture are nonzero and unequal, which is what separates "reconciles" from "ignores the offset" and from "swaps the two offsets".

-

#column_groups_coalesce_per_header_as_the_tui_does

-

(render.rs.) Was column_groups_coalesce_by_name_as_the_tui_does, and its justification was consumed by the consolidation it predates. It read: the run boundary must be the name alone, "because that is what src/ui/grid.rs uses (group_name != prev_group) and the two ends must not disagree". Since improvise-8qlr src/ui/grid.rs uses AxisFrame::group_run_heads, so there is no second copy to agree with — the rule can change once and both ends follow.

-

Its fixture is what settled improvise-h0iv. It merges Month/Q1 (expanded) with Fiscal/Q1 (collapsed), and its own message said the merged span is "drawn with the FIRST one's indicator". That is a collapsed group rendered as expanded, with nothing on screen saying otherwise. Whether two adjacent same-named runs are one span is a question of taste and is why h0iv was filed rather than fixed; a wrong collapse glyph is not. The expectation is now three runs, and the collapse flag of each is asserted — which is what makes this test see the fault at all, since the labels and spans alone cannot distinguish a merge that keeps the first indicator from a split.

-

#a_keyboard_edit_in_progress_is_part_of_the_render_model

-

(render.rs.) A keyboard edit has to be visible, and what is visible is decided in Rust rather than in the page. The failure this rules out is quiet: a client that resolved i locally would change its mode label, put the typing into buffers["edit"], and draw the cell's old value the whole time — so the user sees a keystroke do nothing, types over it, and commits something they never read. None outside an editing mode is the other half: a stale buffer must not draw an editor that is not open.

-
-

#improvise-wasm-client/tests/browser_round_trip.rs

-

The file's scope is improvise-1ey's, quoted: "open an existing document server-side, view it in the browser, type a number into a cell, see the server value update and the projection arrive with the new value." Everything in that sentence except the DOM is Rust on both sides, so the test drives the whole of it: a real Host over an authoritative App, and a real Client reached only through the string calls the page makes. The socket is the one thing left out, and it is covered by improvise-ws-server's own tests/socket.rs; what this adds is the half that crate cannot reach — that the browser client seeds, renders, composes and folds correctly against it.

-

The discipline that makes it evidence rather than decoration: the client is touched only through Client::viewport, Client::on_frame, Client::render_model and Client::typed_cell — strings in and out — and it learns the cell to write from its own render model, never from the fixture. A CellKey anywhere in it would be the test knowing something a browser does not (ADR 002).

-

#a_grouped_row_axis_does_not_shift_the_write

-

A group header is a row of the render model and not a row of the grid, and a write composed against the wrong one lands on a different record — silently, because both are usize. FirstEditable therefore returns two numbers.

-

A fixture with no groups makes them coincide, which is exactly how an earlier version of the first_editable helper handed a visual index to typed_cell and passed (Copilot, PR #23). This test is the fixture the main round trip cannot be: a Coastal header sits between the two rows, so for the second row visual_row and data_row differ. Injecting the confusion — passing at.visual_row to typed_cell — leaves East unwritten. Note the ordering of the fixture is load-bearing: West comes first and ungrouped, because a header before the first row is dropped by AxisFrame::body_from and the two coordinates would coincide again.

-

#a_commit_keypress_is_one_mutation_among_view_commands_and_is_accepted

-

improvise-955n: the one real producer of a multi-command frame must still be accepted. CheckedBatch::against answers a frame's basis before applying any of it, and every mutation answers for its own records. Getting that check right is the easy half; the expensive half is not tightening it until the browser stops working. A commit keypress is exactly the shape at risk — one SetCell plus the mode change, the scroll offsets and the re-seeded buffer around it — and it is produced by keys.rs::commit, which no test in the command crate can reach.

-

So it drives a real keypress through a real Host and asserts the frame is accepted and lands: ServerFrame::Rejected anywhere in the answer, or an unchanged cell, is the regression. Asserting only "no rejection" would pass against a host that dropped the frame on the floor. The "exactly one mutation" assertion is a characterization of what keys::commit emits, not a protocol constraint — a frame may carry more than one — so that if commit ever started batching two, the test would be exercising something else and should be re-read rather than silently re-scoped. And the fixture types 888 after i has seeded the buffer, so the assertion is on the appended value: 888 alone would also be produced by a client that ignored the seed.

-
-

#improvise-wasm-client/tests/lockstep.rs

-

src/keys.rs decides a small set of keypresses without the document. The claim that makes that safe is not "the rule looks the same" but the two ends land in the same ViewState, and that is checkable: the host's reduce_key runs the real keymap, registry and effect pipeline against an authoritative App, and ViewState is the protocol's own type on both sides. So every local outcome is driven through both and compared.

-

This is why the command crate is a dev-dependency. Dev-dependencies are not built for --target wasm32-unknown-unknown, so nothing the host owns reaches the browser bundle.

-

#a_locally_typed_character_lands_where_the_host_would_put_it

-

Driving one mode would say nothing about the others (doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)), so every text-entry mode is driven, and each with a non-empty buffer so an append that silently replaced the buffer would be visible.

-

#a_forwarded_key_reaches_the_host_unchanged

-

An unresolvable key must reach the host as a keypress, and what the host then does to its copy of this client's view slice is what the client will be told. Pinned as the shape the transport owes: the message carries the code and modifiers, and they survive the round trip. Enter rather than an arrow key, because since improvise-bpq2 the arrows resolve locally and enter-advance is one of the movements deliberately left upstream — it aborts the rest of its batch at the bottom-right corner, which is a fact about the host's effect chain and not a cursor position.

-

#a_locally_resolved_cursor_move_lands_where_the_host_would_put_it

-

The headline claim for improvise-bpq2's first gap: a cursor key resolved on the client lands where the host would have put it — including the scroll that follows it, and including the clamp at the end of the grid.

-

The fixture is six rows in a three-row window, and the keys walk past the bottom of the window (so row_offset must move) and then past the bottom of the grid (so the clamp must hold). A client reading its bound off the row frame's length would stop at row 2 and never scroll; one that clamped against nothing would run off the end. Both are visible. Eight presses for six rows: the last three are the clamp.

-

The fixture's one column is deliberate. The two ends measure their windows differently: the host's visible_rows is term_height - 8 and its visible_cols is computed from column widths, while a browser announces both outright. term_height can be chosen to match the rows; nothing can be chosen to match the columns, so the fixture removes the question rather than guessing at an answer.

-

#a_locally_resolved_panel_cursor_agrees_with_the_host

-

improvise-bpq2's second named gap, which turned out not to be one: the category panel's flattening depends on expanded_cats, but the derivation already lives in improvise_protocol::viewmodel and both ends call it (improvise-edp). So this is not two derivations agreeing by luck — it is one derivation, and the test says so by expanding a category, which changes the flattened length the cursor clamps against. A client that counted categories would stop several rows early.

-

#the_buffer_i_seeds_is_the_value_the_host_would_seed

-

i seeds the edit buffer from the cell's display value, and the two ends must agree on what that is: the host reads App::display_value_at_cursor, the client reads the text the server projected into its GridCache. Those are two paths to one string, and this is the assertion that they are the same string.

-

#entering_typing_and_cancelling_an_edit_agrees_with_the_host

-

Esc rather than Enter for the cancel arm, because it is a Binding::Sequence and so exercises the all-or-nothing rule: half of it — a cleared buffer in a mode the user has not left — is worse than none.

-

#a_locally_committed_edit_leaves_both_ends_in_the_same_place

-

What is not compared is the document, and deliberately: the client answers with a Command::Model, which reduce_view drops, while reduce_key writes through the effect pipeline. The write's composition is asserted in keys.rs against typed_cell's own — the shared one — so what is left to prove here is the half a unit test cannot see, which is that the advance and the re-entry land in the same place.

-

#client_ready_to_edit (a fixture, not a test)

-

The published basis a real client folds before it is allowed to compose a write (ADR 001). The tiers are the ones lockstep never checks — the host applies the keypress directly rather than the client's Command::Model — but BasisCache::claim withholds until it has something, so a client without one resolves no commit at all.

-
-

#harness/ — the browser specs

-

These are Playwright tests, so the checker's --check-evidence pass (which scans Rust fn names) cannot see their names. They are recorded here under their exact test("…") titles anyway, because they are where the JavaScript evidence lives.

-

#harness/tests/shell.spec.js — the file's own scope

-

The three assertions improvise-hwpi could not write. That ticket fixed four things and said of itself that the fixes were "as unverified as the bugs", because three of them are one-assertion tests that needed a browser. These are those three, plus the watchdog — which was found by reading, and only because someone went looking. Until this file, web/app.js and web/worker.js had never been rendered or run.

-

Two fixtures are load-bearing. NO_WEBSOCKET_HERE is a peer that accepts the TCP connection and then refuses the WebSocket upgrade — the harness's own static file server — deterministic where a closed port is not: a refused port's close timing is a property of the host's network stack, and on the GitHub runner it did not arrive inside the window. The page cannot tell the two apart and neither can web/app.js: in both cases the socket never opened. HANDSHAKE_TIMEOUT_MS is imported from web/shell.js and restates nothing (PR #65), and one test outlasts it — this sentence said the opposite of both for a while, which is gotcha 48's third clause: a fact stated twice is changed by a grep, not an edit. Since improvise-669b the spec also compares the running page's window against the one it imported, because the single home is in the TREE and the page loads harness/serve/app/shell.js — see outlastTheWatchdog below. logStatus records every distinct #status, not just the last one, because several of these tests are about a message being replaced, which the final value cannot see.

-

#"a frame the client cannot fold says so on screen"

-

improvise-hwpi: on_frame returns a Result, which wasm-bindgen throws. Thrown in a bare listener it reached the devtools console and nothing else — the grid simply stopped updating with nothing on screen saying why. The fault is injected at the peer, not in the page — a mocked socket that answers the handshake with a frame the client cannot fold — so this exercises the real on_frame and the real catch.

-

#"a peer that cannot be reached is named, and not called a disconnect"

-

improvise-hwpi: without an error listener, a connection that never opened left #status blank, and "the server is not running" read exactly like "still connecting". This test no longer says anything about the watchdog, and the paragraph below is why it never could. shell.arm runs only after await transport.ready, which resolves on open — so on a socket that never opens the watchdog is never armed at all, and denying its message here denies an unreachable event.

-

That sentence was already in this file, five lines above an assertion that contradicted it. PR #65 added the watchdog clause and edited this very paragraph, inserting "runFor(10s) advances the watchdog" immediately before the sentence saying it is never armed. Reverting PR #21's routing, or stripping fail() of its sawFrame(), both left that clause passing. So a PR whose subject was "a deleted guard went unnoticed" planted the same shape one level up: three injections, three distinct test names, and two of them proving nothing. What the test guards now is the naming — close on a socket that never opened must not read as "disconnected" (improvise-taxx) — which is the half injection 3 really does exercise.

-

The mechanism, so it cannot come back. outlastTheWatchdog now asks the page timesArmed() > 0 before jumping, and a watchdog denial re-added to this path fails by name. It asks ever armed rather than armed now: by the time the disconnect test jumps, a correct page has already disarmed — that is the property — so armed() would have denied the passing case. Building that guard is what surfaced the distinction, which no amount of reading had.

-

#"a socket that opened and then dropped reads as disconnected, and stays that way"

-

The other arm, which must keep working when the first is fixed: a socket that really did open and then dropped IS a disconnect, and saying so is the whole reason close cannot simply be suppressed.

-

It runs past HANDSHAKE_TIMEOUT_MS on purpose — through page.clock, not through a real wait. On this arm the watchdog IS armed — armWatchdog runs after transport.ready resolves, i.e. on open — so "disconnected" is written while a timer is pending, and the only reason it survives is that close goes through setShellFailure, which clears it. That was argued in a comment and asserted nowhere, and the argument was got wrong from the outside: a bug report (improvise-xps8) claimed the timer fired 8s later and overwrote "disconnected" with "sent no document". It had been measured, honestly, but against a tree predating PR #21 — and the "confirmation" that it survived PR #21 simulated only that PR's opened flag and not its other half, routing BOTH close arms through setShellFailure. A simulated fix that is not the fix you named reproduces a bug the real code does not have, and reads exactly like a confirmation. The ticket was filed and closed invalid. Hence this assertion rather than another paragraph — proved discriminating by making setShellFailure skip its clearTimeout, which makes it fail. Same rule as doctrine(an_injected_fault_must_be_the_fault_you_named), one level up.

-

And it was lost a third time, by the PR that was arguing about exactly this. PR #61 split the browser tests into boundary contracts (Playwright) and unit tests (node --test), and merged these two tests into one that kept only the last-value assertions. shell.test.js's "a loud failure DISARMS the watchdog" covers createShell in isolation and cannot see whether app.js routes the close event through fail() rather than setShell() — which is the half PR #21 fixed and the half that keeps getting lost. Reverting both socket arms to setShell left the node suite 26/26 and Playwright 13/13 green. Restored under their original names, and re-proved with that exact revert: the two-arm revert and the close-arm-only revert each fail "…and stays that way", and dropping the opened flag fails "…the watchdog stays out of it". Three injections, three different tests, none of them the unit test.

-

And the helper guards its own premise. page.clock.runFor on a page where clock.install() was never called does nothing and throws nothing, so a test that forgot the install would assert "the watchdog never spoke" against a watchdog never given the chance — vacuous, and green. outlastTheWatchdog reads Date.now() inside the page either side of the jump and requires it to have moved. Demonstrated both ways: with the install removed the suite passed 6/6, and with the guard in place the same omission fails by name.

-

And it guards its second premise since improvise-669b: that the page runs the constant the spec imported. PR #65 single-homed the handshake window in web/shell.js, which closed the understated spec constant — but the home is single in the tree, not in what the page runs: the spec imports ../../web/shell.js and the page loads harness/serve/app/shell.js, the copy web/assemble.sh put there. A stale serve/ restores the exact vacuity, and review demonstrated it — shrink the constant in source only, revert both socket arms, 6/6 green. createShell now exposes timeoutMs() beside timesArmed() and outlastTheWatchdog reads the running page's value and requires it to equal the import. Injected with the ticket's own recipe (8000 → 100 in web/shell.js, no rebuild): the run goes to 5 passed / 1 failed, on "the page's handshake window is not the one this spec imported", where the same staleness was previously invisible.

-

Note what this does and does not reach: it guards the tests that call the helper. "a peer that accepts and then says nothing trips the watchdog" also names HANDSHAKE_TIMEOUT_MS, but as a Playwright timeout: margin rather than as a window to outlast — a stale value there costs patience, not evidence.

-

The window is single-homed, and that is the second half of the same finding. HANDSHAKE_TIMEOUT_MS lived twice — once in web/app.js, once in the spec — with a comment saying a silent disagreement "would pass for the wrong reason" and nothing checking it. Setting the spec's copy to 1000 against the page's 8000 made the whole suite green against the reverted routing: the jump landed inside the window, the watchdog had not yet fired, and the denial held vacuously. It is now exported from web/shell.js and imported by both, so the two cannot disagree; shrinking it in its one home still catches the routing revert, which is the demonstration that the guard did not go quiet.

-

The faked clock is what makes the restoration cheap without weakening it, and the injections are the proof of that too: if page.clock.runFor did not really advance the page's timers, a broken routing could not surface the watchdog's message and those injections would have passed. They failed. The test keeps the real page, the real socket, the real listeners and the real setTimeout call — only the passage of time is simulated, and 9 seconds of wall clock became 75 milliseconds.

-

The measurement that could not become a CI guard. Both of the above were once asserted as test.fail expected-failures and had to be removed: neither reproduced on the GitHub runner, where close does not follow error within the window. An expected-failure is green when the bug is present and red when it is absent, so one whose trigger is platform-dependent does not merely flake — it inverts CI's meaning, and it failed the build twice saying "expected to fail, but passed". The lesson stands even though the second bug did not: assert the PROPERTY THAT MUST HOLD, which is portable, rather than the fault, whose trigger is the host's network stack.

-

The first of the two, for the record, was real: the socket's close listener was unconditional, so on a connection that never opened it overwrote "cannot reach …" with "disconnected". FIXED by PR #21. An opened flag is the only discriminator — readyState at close time cannot tell the two apart, and neither can the CloseEvent: both cases are code 1006, wasClean: false.

-

#"a peer that accepts and then says nothing trips the watchdog"

-

The first-frame watchdog, found by reading. The condition is one no per-transport check would catch: the peer accepted the connection and then said nothing. The mock accepts and never answers.

-

#"the page draws the demo document out of the worker"

-

No ?ws=, so web/app.js spawns the in-page worker over OPFS: the whole application, with no server process. If this draws a grid, then worker.js, bindings.rs, OpfsFs, vfs::open and the seed fetch all ran.

-

#"committing an edit sends one frame and shows the host's value"

-

A commit round-trips through the host, and what lands on screen is the host's value rather than the string that was typed. 20000 is typed and 20,000 asserted: the number format is applied by the host and relayed back, so a page that echoed the input would fail.

-

This is deliberately NOT named for improvise-hwpi's third fix (the closed re-entry guard in edit), which Copilot asked for on PR #24. That guard is not observable from the DOM, measured rather than assumed: with it removed, close still runs exactly twice, the frame is still sent exactly once, #grid still takes exactly two childList mutations, and the final text is identical. The only difference is one extra entry into repaint() that produces no output — because the second close is close(false), so it does not re-send, and everything else it does is idempotent. That is precisely why hwpi called it "harmless before, but only by accident". Counting repaint() entries would need a test hook inside web/app.js, which would be a page that knows it is being tested. So the test name says what is checked and this entry says what is not. What is observable is asserted: the editor is gone and did not come back, so no stray <input> is left behind and the host's value is not overwritten by the original text the second close writes before repainting.

-

#"the harness can see a failure"

-

Check the checker, and it runs first: a green run against a worker that never started is the failure this repo has a doctrine about. run_canary drives the same reporting path every real check uses, and the spec asserts it is reported as a failure with its message intact.

-

#"every check in the battery runs and passes"

-

The battery is asserted complete before any of it is believed: the report's names must equal EXPECTED exactly, so a build that dropped a check fails rather than passing with fewer.

-

#"what OPFS held survives a reload, and a committed delete does not"

-

The one thing no in-page check can establish. A delete is visible immediately and lands in OPFS at the end of the batch — so "commit_deletes actually removed it" is only answered by a fresh page. This is also the storage half of improvise-bck's persistence criterion.

-

#The opfs-contract checks (harness/opfs-contract/src/lib.rs)

-
    -
  • an_attached_but_unwritten_entry_is_not_a_document — attach must create the file to obtain a sync access handle for a document that does not exist yet, and that created file is zero bytes, which is NOT a document. Storage::list promises document names, so an attached-but-unwritten name must not be listed. Indexing it is exactly the improvise-9x6 browser failure: the seed was skipped as "already there" and load_from got an empty buffer. The check also opens a second instance, which shares no index, because the filter has to be in refresh and not only in the omission from attach — and then writes through the handle to show it is real and the name IS then a document.
  • -
  • a_directory_entry_is_not_a_document — keys() yields DIRECTORIES as well as files, and getFileHandle on a directory rejects with TypeMismatchError. Treating that as "could not measure, so assume a document" would list an entry that can never be attached or read — the same picker bug the zero-byte filter removes (Copilot, PR #24). OpfsFs never creates a directory, so the harness makes one itself, and asserts a real file beside it IS listed — the precondition, so the check cannot pass by the directory never having been created, and evidence that keys() walked the root at all.
  • -
  • refresh_walks_every_key_and_sorts_them — the hand-rolled keys() async-iterator walk in refresh is the least certain code in opfs.rs (improvise-xf1 named it). One entry could pass by accident; several, out of order, cannot. It wipes first, like every other check: a battery whose steps depend on the leftovers of the step before breaks when one is inserted, which is exactly what inserting the directory check did.
  • -
  • read_and_write_are_absolute_at_offset_zero — FileSystemReadWriteOptions' at handling, the other thing xf1 called out. A write that appended instead of overwriting, or a read that started anywhere but 0, is invisible on a single short write and obvious across a long one followed by a short one.
  • -
  • storage_contract_at_… — the point of the whole exercise. attach is hoisted out because acquiring a handle is the async half and Storage::write cannot await; the contract itself is the shared function, not a transcription of it.
  • -
  • a_delete_is_visible_at_once_and_lands_on_commit — both halves, because either one alone would pass against a backend that got the timing exactly backwards.
  • -
-

#a_locally_cycled_panel_focus_agrees_with_the_host

-

cycle-panel-focus is bound to Tab in Normal only: PanelKeymap's spine binds Tab to enter-mode normal inside every panel (gotcha 12), so the wrap arms of CyclePanelFocus — the ones that start from the focused panel — are unreachable by keypress. The test therefore asserts the one reachable path and says so, rather than looping three times and passing on a mode that never moved. The first draft did loop, and failed on step 1 with the mode back at Normal; that failure is the evidence for this paragraph.

-

#a_locally_resolved_tile_axis_change_agrees_with_the_host

-

Both of TileAxisOp's names in one walk, because the command names itself off its axis field: r takes the Some arm (SetAxis) and Enter the None arm (CycleAxis), and a test that drove only one would leave a whole Cmd unmirrored. Injecting parse_axis("row") => Axis::Column fails it.

-

#the_category_panel_bindings_agree_with_the_host

-

The fixture sits on a header row for Space/a and on an item row for Enter/d, and then on a header again for a second d. Both of FilterToItem's arms and both of DeleteCategoryAtCursor's arms are therefore reached. That is not decoration: the first version sat only on the item row, and injecting a wrong sentence into the category arm of delete_at_cat_cursor did not fail it. One fixture position, one arm — and a check that passes against an arm it never enters is the defect this repo keeps finding.

-

#the_view_panel_bindings_agree_with_the_host

-

Two views in the fixture, and the cursor moved to 1 before Enter, because view_at_cursor reading .get(0) is the natural wrong implementation and a single-view fixture cannot tell it from the right one. Injecting .get(0) fails it.

-

It presses d as well, and that was added after the fact: the first version said "create, switch and delete" in its doc comment and pressed only n and Enter, so delete_view_at_cursor — whose only decision is the cursor step-back it makes away from the top of the list — was never run. The adversarial review of PR #50 deleted that step-back and the workspace stayed at 1322 passing. d is pressed at index 1 so the decision is reached, and both_ends now compares every cursor (see below), without which the deletion would still be invisible.

-

#a_locally_committed_minibuffer_agrees_with_the_host

-

commit-buffer for all three kinds, driven as keystrokes so the TextEntry spine's clear-buffer weld is part of what is compared. CommitKind::Item carries its category, resolved from AppMode::ItemAdd — so the mode the test starts in is load-bearing, not scene-setting. Injecting AddCategory in place of AddItem fails it on the document comparison.

-

Formula was missing from the first version, and it is the kind that matters most: it is the only one whose on_commit() is Some, so a test of the other two cannot see the mode a commit returns to. The review of PR #50 broke both CommitKind::status's and on_commit's Formula arms and nothing failed. Its text is seeded rather than typed, and valid, for the reason the next entry gives.

-

#a_formula_the_client_cannot_parse_diverges_on_the_status_line

-

The one thing a client cannot mirror about commit-buffer formula, found by widening the test above and kept as a pinned divergence rather than a fix (improvise-cl7y).

-

CommitBuffer emits its optimistic status first and the add effect last, so on the host a parse failure overwrites "Formula added" with "Formula error: …" (improvise-6t3). The client reproduces the command line for line — which is right — but it cannot parse, so it folds "Formula added" and stops. The user sees a browser confidently report a formula it did not add.

-

This asserts both sides, so a fix has to come here and change it. That is the difference between a pinned divergence and a comment: a_locally_committed _minibuffer_agrees_with_the_host would otherwise have to use a valid formula forever with no record of why.

-

#panel_focus_cycles_from_the_focused_panel_not_the_first

-

CyclePanelFocus's wrap arms, driven directly rather than through a key. PanelKeymap's spine binds Tab to enter-mode normal inside every panel (gotcha 12), so no keypress reaches them — which is why the lockstep test asserts only the reachable path and why breaking the start index survived the review's injection pass. Mirroring a Cmd faithfully includes the arms the keymap cannot reach today; testing them takes a direct call.

-

The fixture leaves the category panel closed, so "the next open one" and "the next one" give different answers and a mirror that walked the array blindly would fail.

-

#a_locally_resolved_clear_and_paste_agree_with_the_host

-

The two writes ADR 002 lets a thin client name. Note the fixture's cell key has three coordinates: Workbook::add_category puts a new category on Axis::Column, so Product is part of the cell's address and a two-coordinate key names a cell the grid never shows. The first version asserted on the two-coordinate key, and passed the per-key comparisons while asserting against a cell nothing had touched.

-

#walking_off_the_end_of_the_help_pages_agrees_with_the_host

-

The bug this was written to demonstrate: effect::HelpPageNext clamps and HelpPagePrev saturates, while reduce_view's Command::HelpPage arm wrapped. Two derivations of one fact, in disagreement, invisible because h/l in the help overlay were upstream keys and no client had ever sent the command. It failed on the first press past the end — client 0, host 4 — and the wire was changed to match the effect. Whether both should wrap instead is improvise-trd3.

-

#"? opens help, and its rows carry the keys and the words, not empty frames"

-

This entry was demoted to prose as Retired when PR #61 deleted the spec. PR #72 restored the coverage under a new name, so the claim is live again and the heading now names the test that exists. What follows is the original evidence; improvise-igxp's stronger assertions are recorded separately below.

-

improvise-ktv1's acceptance in one place: a browser shows help. ? had entered AppMode::Help since the keymap was shared, and nothing appeared — the mode changed and the page had no overlay, which is worse than a key that does nothing because the user thinks they missed it.

-

It asserts the shape, not merely that something is visible: the active tab is the shared module's page title (Welcome, then Navigation after l, which also exercises Command::HelpPage folding on the client), and the body has a key row. Two faults were injected and each failed it. Dropping the paintHelp(model) call fails toBeVisible; removing the mode != Help guard in help_rows — so the overlay is never empty and therefore never hidden — fails toBeHidden. The second is the one worth having: an overlay that is always open passes any check that only ever opens it.

-

It runs against the assembled site like every other test here, so it covers render.rs, the serialized model, web/app.js and the CSS together.

-

It was gone for a release, and that is why the entry reads oddly. 36ade35 deleted it with the rest of harness/tests/widgets.spec.js and nothing replaced it, so the heading promised coverage that did not exist -- exactly the cost improvise-5ray names, and invisible because the evidence checker then read only ### headings and only Rust names. PR #69 taught it to see both; PR #72 restored the coverage under the current name, with the stronger content assertions improvise-igxp asked for.

-

The injections above are the original two and still describe the property. The present test is harness/tests/help.spec.js, and its own entry below records what igxp added: the rows are read as dt/dd pairs and asserted as text, so blanking keys.textContent fails it where toBeVisible passed.

-

#the_shipped_demo_seeds_a_worker_whose_raw_cells_are_settable

-

improvise-ir7l renamed this from ..._whose_every_cell_is_settable and narrowed what it claims. The old loop called MainThread::cell — which panics on anything but a stored cell — over every column including Profit, which web/demo.improv defines as Revenue - Cost. It passed only because a formula target projected CellId::Record, which is the bug the ticket names: a test whose name was false for a third of the grid it walked.

-

It now walks the two raw columns and asserts separately that the Profit column is CellId::Derived, so it fails if either half moves. That second assertion is what makes the narrowing honest rather than a retreat — dropping the column would have left the page free to offer it again with nothing noticing.

-

#the_page_keys_turn_the_odometer_as_the_command_does

-

improvise-p1ym. GridCache::page_coords carried (category, selected item) and nothing else, so [/] resolved to a command name the client held but could not compose an argument for. They reached Host::handle, which answers an empty vec — doctrine(an_upstream_key_is_a_black_hole) — so an independent smoke test found them inert against the live page, where Quarter sits on the page axis precisely because paging is a thing a visitor tries.

-

The test asserts the shape composed, not that something resolved: two page axes, the least significant at Q4, so one press must both wrap Quarter and carry into Year. A resolver that moved only the first axis, or carried the wrong way, fails it.

-

#a_filter_axis_is_projected_but_not_cycled

-

The gotcha-27 arm. Page and Filter both constrain which records are visible, and GridLayout::page_coords is deliberately filtering_cats() (Page ∪ Filter) because the layout does not care which the user can navigate. The projection copied that verbatim, so a client handed the list would have tried to cycle a drill's fixed coordinate.

-

PageAxis::items is Some only for Axis::Page, so the difference is in the type rather than in a rule each client remembers. The test adds a Filter entry to a client that is otherwise paging normally and asserts the two Page axes still move while the Filter one does not — an assertion that only the Filter axis is skipped, not that paging stopped.

-

#a_locally_resolved_page_turn_lands_where_the_host_would_put_it

-

The lockstep claim, and the first version of it was vacuous, which is worth recording because it looked exactly right. It rebuilt the client's cache from the host after every press and then compared the client's cache against that same host's document — two readings of one value. Reversing the odometer's significance in page_cycle left it green.

-

It now drives two independent documents: one Host fed the commands the client composed, one App fed the same keypress through reduce_key, compared on View::page_selection — the document, never a projection. Proved discriminating by that same injection (for i in (0..axes.len()).rev() → for i in 0..axes.len()), which now reports Q2 against Q3. The assert_ne! against a fresh fixture is the second half: without it, a resolver that emitted nothing at all would pass by moving neither.

-

#harness/tests/widgets.spec.js — the file's own scope

-

Both files were deleted by PR #61, which moved the pointer's and the widgets' browser halves into harness/tests/contract.spec.js and the rest into node --test web/tests. What follows is the history of how these assertions were arrived at; the assertions themselves now live there.

-

The file is gone; its subject is not. All four widgets it named were one failure at one boundary — a RenderModel field with no element painting it — and that is now asserted once, from both sides, by web/tests/render_surface.test.js and harness/tests/contract.spec.js. What is not carried over is each key's meaning, which improvise_protocol::resolve decides and tests. Everything below is the history of the bug, and still true.

-

A user reported three things as broken: "I can't expand categories, and the Tile + View UIs don't work." None of the three was a regression. Driving the assembled site under Chromium separated them, and the separation is the point: two of the three had every layer but the last one, and the layer they were missing was not the same in each case.

-

Category expand already worked, by keyboard. Shift-C, j, Enter folds Command::ToggleCatExpand client-side and the items appear. What does not work is the mouse: categoryList builds plain <li>s with no listener, so a click on the triangle does nothing at all. That half is improvise-7ybz, and it is a product decision about the whole page's pointer model rather than a defect in an implemented feature — #grid offers a click only on an editable cell, so there is no click-to-select either.

-

The tile bar and the view panel had the cache, the keymap, the resolver and the host round trip, and no DOM. Before the fix, render_state() in the browser carried five TileBarCache entries and both ViewListCache views; Shift-T entered TileSelect and ArrowRight moved tile_cat_idx; r/c/p reached the host and really did move a category between axes. RenderModel simply had no field for either, and web/index.html had no element. So the keys worked and the user was aiming them at nothing — which reads exactly like "the Tile UI doesn't work". The formula panel was the third of Panel::ALL in the same state and is fixed by the same mechanism.

-

The fault injection was the unfixed bundle itself. Run against the tree at a3bef6e, the category test passes and the other four fail — #tiles, #views and #formulas resolve to zero elements. That is the evidence the four tests are discriminating, and it is why the category test stays: it is the control that says the harness really is driving the keyboard.

-

Two assertions in the first draft were wrong, and both corrections are worth keeping. Axis::short_label() is three columns, so a Page tile reads Quarter Pag, not Quarter Page — a test asserting the long spelling would have forced the page to invent a label instead of echoing Axis's own. And switching views leaves the panel open: switch-view-at-cursor returns to Normal and never closes the panel, exactly as src/draw.rs keeps drawing it off view_panel_open alone. Asserting it hidden would have pinned the browser to behaviour the TUI does not have.

-

The gating rule itself — an empty list is what says a panel is closed, the same rule help follows — is asserted in Rust as well, by a_closed_panel_projects_no_rows_and_an_open_one_does and the_tile_bar_is_always_present_and_only_its_cursor_is_modal, because a browser test cannot distinguish "closed" from "the cache is empty" and those two tests fill the caches first.

-

What these tests do not cover: the pointer (improvise-7ybz), and the inconsistency that #views/#formulas honour their open flags while #categories is drawn unconditionally because app.js has never read category_panel_open (improvise-wsaw).

-

One assertion was added after the first green run and is worth naming: the tile test originally asserted only that the tile label changed to Product Col. That is the bar reporting itself, which is exactly the shape of test this repo keeps catching — it would pass against a tile bar that displayed an axis the document had not taken. It now also asserts the grid reshaped: Espresso moves from a tbody row header to a thead column header. .first() is needed there because Product then shares the column axis with _Measure, so the label appears once per header level — which is itself a small confirmation that gotcha 44's multi-level frame reaches the browser.

-

Three review findings, applied. The second is the one worth carrying.

-

The page had two spellings of the same triangle, inside the file that declares it has one. indicator() carries the comment "only this spelling is the page's (gotcha 26)" — singular — and categoryList did not call it: it inlined U+25B8/U+25BE (the SMALL triangles) where indicator(), the page's own grid group headers and src/ui/category_panel.rs all draw U+25B6/U+25BC. So the browser's category panel disagreed with the TUI's and with its own grid. categoryList now calls indicator(!row.expanded).

-

And the first version of this spec pinned the wrong spelling. It asserted /▸ Product/ and ▾ — the divergent pair — so the obvious cure, which the file's own comment implies should already be in place, would have failed a test this work added. That is this repo's characteristic defect exactly: not a wrong line, a test that cements a state the doctrine says is wrong, and it makes the cure more expensive the longer it sits. The spec now asserts ▶/▼, and the change was proved discriminating by running it against the pre-fix bundle, where it fails with Received string: "▸ Product (4) R".

-

Note what this does not settle: whether glyphs should move to the protocol the way short_label() did. That is improvise-h04z, not answered here — what is fixed is the page contradicting itself. viewRow still spells "▶ " as a literal duplicating src/ui/view_panel.rs:62; the two agree today, which is precisely the state categoryList was in before it drifted.

-

And the drift was already spreading, which is the evidence that ticket needs. harness/tests/pointer.spec.js, written on a different branch, had independently grown the same ▸/▾ assertions — so the corrected indicator() broke it too. Three files converging on a spelling nothing compares is the failure design-principles §5 predicts, observed rather than argued, and it is what answers "is one duplicated literal worth a protocol change" with data instead of doctrine.

-

el.tiles.hidden was a no-op, removed with its comment. #tiles declares display: flex, an author-origin rule that beats the UA sheet's [hidden] { display: none } at any specificity, so the guard never hid anything and "an empty tile list draws nothing at all" was false. #views, #formulas and #help declare no display and so are genuinely hidden by the attribute. Worth knowing before anyone "fixes" it: because #tiles is always laid out, its height never changes, so it feeds nothing back into the ResizeObserver — restoring the guard would introduce exactly the first-paint height change the observer argument assumes away.

-

The ResizeObserver loop is no longer "argued, not tested", and the proof is worth more than the measurement. It was measured: 820 combinations — four panel states (none / views / formulas / tile-select) × heights 360-440 in 2px steps × five widths straddling the COL_PX = 110 boundary, each counting #grid child replacements in a 500ms quiet window. Every size settled; nothing repainted twice. But the reason is structural, so the measurement is a check on it rather than the evidence itself: the frameCost dependency graph is acyclic. frameCost.cols is row_levels, the number of row-axis header levels, which does not depend on how many rows are projected. frameCost.rows derives from col_levels.length and col_groups, which depend on the projected column window, which depends on width and frameCost.cols. So cols → rows, and nothing depends on rows. There is no cycle to oscillate. A change that made either term depend on the projected row count would create one, and that — not the pixel arithmetic — is the thing to check.

-

A harness port collision can make a run test somebody else's tree, silently. playwright.config.js hard-coded 8099 with reuseExistingServer: true, so a python3 -m http.server left by another worktree is adopted, and the run then exercises that checkout with no warning. The port is now IMPROVISE_HARNESS_PORT, defaulting to 8099 so CI is unchanged. The tell is a result that disagrees with the file you just read: check lsof -a -p <pid> -d cwd on the listener before believing it.

-

It fires in both directions, and the dangerous one is the false PASS. Two agents hit it in one session. One got a false negative — a glyph fix reported absent, because the adopted tree did not have it — which is merely confusing, since a failure sends you looking. The other got widgets.spec.js reporting 5 passed against a tree it had content-verified as having no #tiles and no paintTiles at all, which is impossible and was caught only because that agent knew what the tree contained and refused to believe the number. A false pass prompts nobody. That is what makes a hard-coded port with reuseExistingServer a correctness bug rather than an inconvenience, and it is the same rule as the rest of this file: a green result is evidence only once you know what it measured.

-

#a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row

-

improvise-7ybz's whole difficulty in one assertion, and the reason the click resolvers take an index rather than reading the cursor.

-

A click means three things at once — focus the panel, move its cursor to the row under the pointer, activate that row — and the third is resolved in the same pure call as the first two, before any of them has been folded. So filter_to_item_at(state, index) must be asked about index; asking filter_to_item(state) reads view.cat_panel_cursor, which is still wherever the keyboard last left it. That is why cat_tree_entry, toggle_cat_expand, filter_to_item and tile_axis each gained an _at twin and the cursor-reading form became a one-line call of it: one definition, two entry points.

-

Injected fault, run: commands.extend(filter_to_item_at(state, index)…) → filter_to_item(state). The test fails with ToggleCatExpand("Region") against ToggleCatExpand("Product") — the panel cursor lands on the row clicked while the expansion happens to the row that was selected before, which is the silent half. The fixture therefore has two categories and clicks the second: with one category, or with a click on the row already under the cursor, the bug is invisible.

-

The sibling a_click_in_an_already_focused_panel_only_moves_and_activates pins the other half: focus emits only what is not already true, so a click inside the focused panel is not a mode change on every click. Without it, "always emit SetPanelOpen + ChangeMode" passes the first test.

-

#a_cell_click_is_absolute_even_though_the_page_paints_a_window

-

The pointer's version of the_selection_is_placed_by_subtracting_the_scroll_offset, and it fails the same way: GridCache is viewport-indexed, ViewState::selected is absolute, so a click at painted (2,1) under offsets (12,3) is (14,4). Offsets are nonzero and unequal for the same reason as its sibling — equal offsets cannot separate "reconciles" from "swaps the two". The second half asserts a click past the grid's extent resolves to nothing, which is what stops a click on the padding below the last row selecting a cell that does not exist.

-

#a_click_past_the_last_row_resolves_to_nothing

-

doctrine(check_the_checker) for the two above: the empty-answer path. Without it, a click that returned Vec::new() unconditionally would pass every "resolves to nothing" expectation while failing silently everywhere else — so this one is only meaningful because its neighbours assert non-empty answers.

-

#harness/tests/pointer.spec.js — the file's own scope

-

Both files were deleted by PR #61, which moved the pointer's and the widgets' browser halves into harness/tests/contract.spec.js and the rest into node --test web/tests. What follows is the history of how these assertions were arrived at; the assertions themselves now live there.

-

The file is gone; one test carries it. contract.spec.js's "each clickable surface is wired to its own index" keeps what only a browser can say — the handler is on the element, it passes that element's index, and the answer is painted — against three surfaces. What each click means is improvise_protocol::resolve's, and its tests are named below. The six tests' injection stands as recorded.

-

The user's ruling on improvise-7ybz ("build it — one entry point per widget") and improvise-wsaw ("honour the flag"), executed in a real browser.

-

Six tests, and all six fail against the bundle built immediately before them — the tree that already had the three widgets drawn (PR #55) but no click handlers and no category_panel_open read. That is the injection: the sidebar is visible when it should be hidden, and every click() resolves to a <li> that has no listener, so nothing moves. The category test is the sharpest, because the element it clicks exists and looks interactive in the unfixed tree — which is exactly what the user hit.

-

Two properties here that the Rust tests structurally cannot reach. The pointer and the keyboard are one cursor: the keyboard continues from where the pointer left the cursor clicks a cell, closes the editor, presses ArrowLeft, and requires the move to start from the clicked cell — if the click had only opened an editor (the pre-fix behaviour) the arrow would move from the origin instead. And the tile click is asserted against the grid, not only against its own label: Espresso must appear as a thead header, because a tile bar reporting an axis the document did not take is the failure shape this repo keeps finding.

-

One fixture note worth keeping: openPanelThenLeave opens the panel with Shift-C and then presses Escape, which returns to Normal without closing the panel (PanelKeymap's Esc is enter-mode normal, nothing more). That is the only state in which the "focus" third of the click is observable — from a freshly opened panel the mode is already CategoryPanel and the assertion would pass without the focus commands existing at all.

-

#harness/tests/drill.spec.js — the file's own scope

-

The fourth widget the user reported, and the one with a different answer from the other three. The tile bar, view panel and formula panel were each one RenderModel field short — cheap. Drill is not: there is no wire vocabulary for it, which is improvise-gd28, and these tests document the refusal rather than pretending the gap is a bug to be fixed here.

-

Where it stops, by layer. Everything below the command exists and was verified in the browser, not read: project::grid_of emits CellId::Aggregate, the client receives it ({"Aggregate":"750ab1f5ffa96d5e…"} in render_state), render.rs::editable correctly answers false for it, and resolve.rs reaches the aggregate arm. What is missing is one link: reduce.rs contains no Drill variant at all — not Command, not ModelCommand. The seam is ready and the sentence is not sayable.

-

Why it was left out is a decision, not an oversight (gotcha 38): drilling creates the _Drill view and writes drill_state, which gotchas 30 and 1 rule session-local — pending_edits are keyed by (row, RecordCol) and belong to whoever staged them. A Command::Model is routed with no SessionId, which is right exactly when a change is addressed to no one session; drill is addressed to precisely one. That is the same argument that removed ApplyAndClearDrill from the wire in improvise-3x3u, so adding ModelCommand::Drill would reintroduce the bug that removal fixed. It needs the addressed form — ADR 001 layer 2.

-

The two entry points used to fail differently, and that asymmetry was the finding. i/a on an aggregate declines out loud: edit_or_drill answers the CellId::Aggregate arm with a status the user can read. But > (drill-into-cell) was a name resolve::step did not handle, so it became KeyOutcome::Upstream → ClientFrame::Key → Host::handle, whose Key arm is an explicit empty vec — a black hole, not a slow path (gotcha 47). Pressing > in the browser did nothing whatsoever: no status, no mode change, nothing. That is why this read as "never confirmed working" rather than as a refusal.

-

That silence is now closed, and the closing is what this file ships. step answers "drill-into-cell" with a SetStatus saying drill is not available in the browser yet — an interim the wording of which is load-bearing, since it must be true both today and on the day improvise-gd28 lands. The wire vocabulary is still absent; only the silence was fixed. Note for gd28: when the real drill arrives, a leftover "drill-into-cell" arm in step would shadow it with nothing failing, because a name that resolves never reaches the host.

-

The TUI is the control and it works: 12 drill tests pass across cmd/grid.rs, cmd/mode.rs, ui/effect.rs and ui/app.rs. So this is isolated to the client/wire, not a general regression.

-

A fixture fact that is itself part of the answer. web/demo.improv has no aggregate cell at all — gotcha 46 puts exactly one category on each visible axis precisely so every cell is a Record, and tests/web_demo_model.rs asserts it. So on the shipped demo a user cannot even reach the refusal without first changing an axis. anAggregateGrid therefore makes one, through the tile bar, the way a user would: Quarter cycles Page → None and every cell becomes an aggregate. The precondition is then asserted (td.editable count 0) rather than assumed, so neither test can pass against a grid of Records.

-

Both injections, run — against the tree before the interim, which is what made them two faults rather than one. Replacing the aggregate arm with None — silencing the honest refusal — failed both tests, and the second failed on its check_the_checker control, which is the point: with nothing answering, the control can no longer tell "> is silent" from "the page is dead", and the test correctly refuses to pass. Adding "drill-into-cell" => Some(vec![SetStatus(…)]) to step — making > resolve — failed only the second test and left the first green. Two faults, two different failure signatures, neither test passing for the other's reason. That second injection is now the shipped code, which is why the test it broke was rewritten rather than kept.

-

That control is load-bearing and not decoration: a test asserting nothing 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

-

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".

-

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.

-

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.

-

#a_view_row_click_focuses_moves_and_switches_to_the_row_clicked

-

improvise-iioi, and it is the same trap as the category click one level over: the activation must be resolved against the row clicked, never view_panel_cursor, because the SetPanelCursor ahead of it has not been folded when the resolver runs. Injecting exactly that — reading the name at the cursor instead of at index — yields SwitchView("Default") beside SetPanelCursor { cursor: 1 }, i.e. the cursor lands on Plan and the document switches to Default. Silent, and wrong in the half a user does not watch.

-

The fixture therefore holds two views and clicks the second. With one view, or with a click on the row already under the cursor, the bug is invisible — the same reason a_click_focuses_the_panel_moves_its_cursor_and_activates_the_row clicks Product rather than _Index.

-

#a_formula_row_click_navigates_and_activates_nothing

-

The negative arm, and it is a statement about the rule rather than about the formula panel. The rule is "go there, then do what Enter does there"; ModeKey::FormulaPanel's keymap binds a/n/o to enter-mode formula-edit and d/Delete to delete, and no Enter at all. So the rule yields navigation and nothing else, and that is the rule applying rather than the fix stopping half way.

-

This test asserts the exact command vector, which is the whole of what it can say. It once carried a second, explanatory assert! that no FormulaEdit mode change appears — which could never have been the failing assertion, because the assert_eq! above it pins the same three commands and aborts first. A reviewer demonstrated it by injecting a ChangeMode(FormulaEdit) and watching the panic land on the assert_eq! with the explanatory message never printed. It was a comment wearing an assert!, and the argument now lives in context/code/clients.md where a comment run cannot hold it.

-

#the_formula_panel_has_no_enter_to_mirror_and_the_view_panel_does

-

The guard that the removed assertion was pretending to be. The asymmetry — a view-row click activates, a formula-row click does not — is a fact about the keymap, and nothing tied the click to it. The reviewer proved the gap by adding Enter -> enter-mode formula-edit to ModeKey::FormulaPanel and running the whole workspace: 1381 passing, nothing failed, while click_formula_row still activated nothing. The click and the keyboard then disagree about what a formula row does, silently.

-

Injecting that same binding now fails this test and only this test. The failure message names click_formula_row as what must change, because the person who adds an Enter binding is not the person reading resolve.rs's click arms. The positive half (ViewPanel does bind Enter) is there so the test cannot pass by asserting that no panel binds anything.

-

#The view and formula row clicks in the browser (improvise-iioi)

-

These began as two tests in harness/tests/pointer.spec.js. That file no longer exists: PR #61 deleted it and moved the pointer's browser half into harness/tests/contract.spec.js, whose stated scope is the wiring — the handler is on the element and passes that element's index — while what a click MEANS is improvise_protocol::resolve's and tested there. The assertions were merged into that file's own click test rather than restored, because resurrecting a file a merge deleted is how a deliberate removal gets undone by reflex.

-

They leave the panel open but unfocused — the only state in which the focus third of the click is observable.

-

History, not current evidence: the original pair was proved against the bundle built immediately before the fix, both failing there while the pre-existing pointer assertions passed. That run was against harness/tests/pointer.spec.js, which no longer exists. What holds the relocated assertions honest is the injection below, run in their new home.

-

The view test asserts three things — #view-name, the active marker and the selected cursor — because switching ends in NORMAL and a test that only checked the mode would pass against a click that did nothing but leave the panel. The relocation initially dropped two of the three, and a reviewer caught it; they are restored.

-

And the fixture was vacuous in a way the prose had already warned about. Default is index 0 and holds view_panel_cursor by default, so clicking it — which both the old pointer.spec.js and the first relocated version did — makes index and the cursor the same number, and reading either gives the same name. A reviewer injected switch_view_at(state, state.view.view_panel_cursor) into click_view_row and the whole contract suite passed 3/3: the exact fault this test, the ticket and the Rust test all exist for was invisible to the browser half. The fixture now switches to Default by keyboard, leaving the cursor there, then clicks Plan — so the two readings name different rows and the outcomes differ. Re-running that injection now fails it.

-

The blindness was inherited rather than introduced: the deleted file clicked Default too. It became worth fixing because the relocated test is now the only browser assertion on that surface.

-

One fact the reviewer's report supplied and this fix leans on: shipping the cure for one panel and not its two siblings reproduces the original report. A user who has just learned category rows are clickable clicks a view row and reads the silence as broken — which is literally what "I can't expand categories" meant.

-

#The harness port: reuse is now opt-in

-

reuseExistingServer: !process.env.CI became === "1" on IMPROVISE_HARNESS_REUSE. A default-on reuse adopts a server another checkout left on the port and then tests that tree, which fired in both directions in one session — a false negative and, worse, a false positive. A path-hash default was considered and rejected: it still silently reuses when two agents share a worktree path, where an explicit opt-in cannot. CI is unaffected (it never reused) and a developer wanting the old speed sets the variable.

-

#a_click_that_cannot_compose_its_activation_composes_nothing

-

improvise-hkr8. click_category_row ended with filter_to_item_at(state, index).unwrap_or_default(), and that None arm is not "nothing to do" — it is BasisCache::claim answering that the server has published no basis yet (gotcha 43), so the document change cannot be composed. The click therefore kept its navigation prefix (focus the panel, move the cursor), dropped its activation, and reported success. The keyboard handles the identical None correctly, by mapping step(..) == None to KeyOutcome::Upstream.

-

The fixture has to do two things a careless one would not. It expands Region so that row 1 is an Item row — a header row activates via ToggleCatExpand, which needs no basis and so cannot show the bug — and it asserts both preconditions (cat_tree_entry_at is an Item, claim is None) rather than assuming them, because an empty ClientState makes every state-dependent refusal look like the one under test.

-

Injecting the old unwrap_or_default() fails this test and only this test.

-

#the_same_click_composes_everything_once_a_basis_is_published

-

The positive arm of the one above, and it is what stops the fix from being "return nothing more often". With a basis published, the same click composes all six commands: open, focus, move the cursor, two Command::Models and the status. Without this, deleting the activation entirely would pass hkr8's test.

-

#a_first_tile_click_selects_without_touching_the_document

-

The user's ruling: a click selects, a second click cycles. The fixture publishes a basis deliberately, so the absence of a Command::Model is a decision rather than a client that could not compose one — pointed at an unpublished basis this test would pass for hkr8's reason instead of its own.

-

Both halves are asserted, because "the axis did not change" passes against a page where nothing happened at all: the positive half requires SetTileCatIdx plus ChangeMode(TileSelect), the negative half requires no Command::Model.

-

Four injections, four distinct signatures. "First click also cycles" fails this test, the_first_click_on_tile_zero_selects_it_like_any_other and clicking_a_different_tile_selects_it_rather_than_cycling_it — and not a_second_click_on_the_selected_tile_cycles_its_axis. "Second click does not cycle" fails only that last one. "already_selected reads the index alone" fails only the tile-zero test. Reverting hkr8 fails only the hkr8 test. No injection passes for another's reason.

-

#t_then_a_click_on_the_leftmost_tile_cycles_it_immediately

-

Found on review, and it is a defect in the wording rather than the code. T enters TileSelect leaving tile_cat_idx at 0, so a click on the leftmost tile is a click on the already-selected one and cycles on the first gesture. Both loose summaries of the ruling — "a click never mutates on its own" and "a second click cycles" — are false against that probe.

-

The behaviour is right: a click does what Enter does there, and Enter in TileSelect cycles the tile under the cursor. The resolver cannot see how the state arose and must not try to. So the test exists to name the case rather than to change it, and it asserts the neighbouring tile answers the identical gesture by selecting — because the surprising half is that two adjacent tiles differ, and a reader who meets that in the wild should find it written down.

-

Note what this means for its sibling a_second_click_on_the_selected_tile_cycles_its_axis: that test sets the state directly rather than by clicking, so it already was the keyboard-entry case. Nothing was untested; the name and the argument were missing.

-

#enter_in_an_empty_view_panel_resolves_locally_rather_than_upstream

-

switch_view_at returns Option, and its two callers answer the None differently on purpose. From a click_*, None means "this gesture composed nothing" — a dynamic fact, and hkr8's rule. From step, None means "this client cannot execute this command name" — a static fact about the vocabulary, which the_command_names_that_still_need_the_host_are_these exists to keep static. Two questions sharing one type, which is why the asymmetry reads wrong at first. (delete_view_at_cursor already answered Some(Vec::new()) the same way, so this follows the file rather than inventing.)

-

Nothing held the keyboard side to it until this test. A reviewer removed the unwrap_or_default() so out-of-range propagates None — making Enter in an empty view panel KeyOutcome::Upstream, gotcha 47's black hole — and the whole workspace passed under --no-fail-fast. That would make membership of the host-needed list state-dependent, the one property that list denies. Injecting it now fails this test and only this test.

-

The assertion is Ignored, not Local(vec![]): KeyOutcome::of maps an empty vec to Ignored by construction, so Local is never empty. What matters is that it is not Upstream.

-

#the_first_click_on_tile_zero_selects_it_like_any_other

-

The trap in the ruling's own wording. "A second click on the already-selected tile" reads as an index comparison, and tile_cat_idx is 0 before anything has selected anything — so an index-only test would read tile 0 as already selected and cycle it on its very first click, making the non-mutation guarantee true of every tile but one. The condition is mode == TileSelect and tile_cat_idx == index.

-

#clicking_a_different_tile_selects_it_rather_than_cycling_it

-

The other half of the same trap, from the opposite side: without it, "a click does not mutate" would hold only for the first click of a session, since every click after the first is a "second click" in the loose reading. Clicking A then B must select B.

-

#How to know a launch default has stopped disarming things

-

The question this left open was how to know you have exhausted the class. Grep does not answer it: grep finds sites that name the flag, and the hazard is a test that merely starts from a default-built state and asserts something downstream, mentioning nothing. Three disarmed tests were found by grepping category_panel_open; that was luck, not method.

-

Mutation answers it, because it does not care what a test mentions. The recipe, which a reviewer ran across five sites in about twenty minutes:

-

For every production site that writes, propagates or reads the value, replace it with the constant the default supplies, and run --no-fail-fast. Sites where nothing fails are suspects. Then re-run each suspect with the default inverted. If it now fails something, the test was vacuous — the default disarmed it, and the fix is the fixture. If it still fails nothing, it was simply unguarded — a different problem, pre-existing, and a bead rather than a fixture.

-

The second step is the one that matters, and without it the two classes are indistinguishable: both look like "nothing failed". It is what established that the class is closed at two (set_panel_open_and_cursor, panel_commands_address_the_named_panel_only) and that two further silent sites are older problems this ruling did not cause:

-
    -
  • ui/app.rs's App::cmd_context propagator — hardcode it true and the whole workspace passes. registry.rs reads it for toggle-panel-and-focus's open-vs-close decision, so C on a closed panel would decide to close and nothing would fail. Invisible because cmd/mod.rs's test helper sets the field explicitly, so no command test reaches the propagator. Filed.
  • -
  • improvise-wasm-client/src/render.rs's RenderModel field — hardcode it true and Rust sees nothing at all; only the Chromium harness catches it, at contract.spec.js's toBeHidden. That is literally improvise-wsaw reintroduced one layer up, and a Rust-only CI run cannot see it — which is the sharpest argument on file for why the browser job must be able to fail the build.
  • -
-

And a repair needs its own probe, because a repair can be shaped like a fix. The two-step recipe above finds a disarmed test; it does not tell you whether the repair you then write is real. That gap is not hypothetical — the first repair of panel_commands_address_the_named_panel_only folded [open:false, open:true], read as a fix in review, and was still vacuous.

-

The probe that settles it is a one-directional fault: make the writer if open { set true }, so the flag can be raised but never lowered. A vacuous repair cannot see this, because a test that only ever asserts the true direction is satisfied by a writer that only ever writes true. Injected into both copies at once, it fails set_panel_open_and_cursor and panel_commands_address_the_named_panel_only, each in its own copy — which is what establishes both repairs are genuine rather than merely differently spelled. Prefer it to re-reading the diff: the diff contained open: false in the version that was vacuous.

-

Note resolve.rs's panel_open reader is well guarded by contrast: hardcoding it true fails two click tests. So the picture is not "everything touching this flag is untested"; it is specifically the propagators.

-

#the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it

-

Prospective rather than present-tense, and the doc comment says so. With three panels, the_terminal_app_launches_with_the_category_panel_open and the_other_two_panels_are_shut_at_launch already cover every Panel::ALL member, so this test cannot currently fail without one of them failing too. A reviewer confirmed that by control.

-

It earns its place the way gotcha 12 describes Panel::ALL: a fourth panel reaches it from one ALL entry, and at that moment it becomes the only test asserting that App::new did not quietly give the TUI its own launch state — the divergence improvise-wsaw closed. Recorded here so nobody later reads a green tick as evidence of something it is not yet checking.

-

#the_terminal_app_launches_with_the_category_panel_open

-

The user ruled (2026-09-20) that the category sidebar is visible at launch in the TUI as well as the browser, so improvise model.improv opening with it showing is intended. This is the guard on the front end the ruling newly changes: a_fresh_session_opens_with_the_category_panel asserts ViewState::default() in the protocol crate, and nothing downstream of it.

-

Why not a rendered-frame test: design-principles §7 says not to test ratatui Widget::render, and there is no TestBackend anywhere in this repo. The honest target is the TUI's own constructor. src/draw.rs's one-line read of the flag stays argued-not-tested, as gotcha 48 has it.

-

Three injections. Putting the shared default back to false fails this test and a_fresh_session_opens_with_the_category_panel. Making App::new override the default (app.view_state.category_panel_open = false) fails this test and the_terminal_constructor_inherits_the_shared_default_rather_than_setting_it — which is the one that would catch a TUI-only launch state being reintroduced, the divergence improvise-wsaw closed. the_other_two_panels_are_shut_at_launch stops "open everything at launch" passing the first.

-

#set_panel_open_and_cursor

-

The launch default silently disarmed this test, and that is the finding. It did SetPanelOpen { Category, open: true } then asserted the flag was true — but test_app() builds from ViewState::default(), where it is already true. Its Formula and View siblings still default to false, so only the category arm went vacuous.

-

What that cost, demonstrated: replacing Panel::Category => app.view_state.category_panel_open = self.open with a no-op — so no effect could open or close the TUI's category sidebar at all — left the entire workspace green. C could be made to do nothing in the terminal with 1391 tests passing.

-

The fix is the fixture stating its own precondition: close the panel first and assert it closed, so the effect has to move the flag before the true direction is asserted. Injecting the no-op now fails this test.

-

Note the harness cannot see this one, and that is correct rather than a gap: the browser folds Command::SetPanelOpen through reduce_view, a different three-way match in a different crate from effect::SetPanelOpen's. Two derivations of one mapping — which is why the reduce_view guard needed the same repair independently. Since improvise-56ek there is one derivation, so that is history rather than a standing hazard; see below.

-

#a_panel_command_writes_that_panel_and_no_other

-

The reduce_view half of the one above, and the first repair of it was still vacuous — worth recording because the mistake is subtle. Folding [open: false, open: true] and asserting the final state is true passes against a reducer that folds nothing at all: the default is already true, so neither command needs to land. Only asserting the false direction discriminates, because that is the one the default cannot supply. Caught by injection rather than by reading — the no-op fold reported NONE failing after the first fix.

-

improvise-56ek then removed the second derivation entirely: ViewState::panel_open_mut and panel_cursor_mut (plus their read halves) are the one place a Panel becomes a field, and both effect::SetPanelOpen::apply and reduce_view call them. The cure is one home, not a test holding two copies in step (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)), so there is deliberately no "the two agree" test — a wrong field in the one home is a failure in every caller.

-

This test replaces panel_commands_address_the_named_panel_only, which drove Panel::Category only and so could not tell one panel from another. It keeps that test's lesson by zeroing every panel before opening one.

-

Injected fault: point Panel::Category's arm at formula_panel_open. Fails by name for every panel in Panel::ALL.

-

That injection alone was not enough, and the gap is gotcha 3's shape. The first version asserted only through panel_open/panel_cursor — the read halves of the mapping it was testing. A consistent permutation of all four accessors is a self-consistent bijection: injecting Formula↔Category into panel_open_mut, panel_open, panel_cursor_mut and panel_cursor together left the test passing, while two unrelated tests that read raw fields failed. It caught only a non-injective mapping, which is exactly the injection first recorded here. The test now reads the three field pairs by name once per panel, and the consistent permutation fails it: "Formula is not the field Formula names".

-

Also corrected here: the claim that consolidation left "no second copy to agree with" was false when written. resolve.rs carried a fourth copy of the same Panel → ViewState three-way match, in the same crate on the same type; it is deleted and its three call sites go through ViewState::panel_open. What remains is not the same mapping: registry.rs maps Panel to CmdContext fields, a different type whose values are copied from view_state, and resolve.rs/registry.rs each map Panel to a (cursor, collection length) pair, which is a second derivation of something else and is lockstep.rs's subject.

-

#a_fresh_session_opens_with_the_category_panel

-

The user's ruling that the category panel is visible at launch, implemented as the shared ViewState::default() rather than a browser special case — a browser-only default is "the page overrides the flag", which is the same defect as the "the page ignores the flag" bug improvise-wsaw fixed. The consequence, accepted deliberately, is that the TUI opens with the panel too.

-

The test also asserts the other two panels stay shut, so it cannot pass against a default that simply opens everything.

-

Two existing tests had to change, and both were pinning the old decision by inheriting it rather than stating it. panel_focus_cycles_from_the_focused_panel_not_the_first is about skipping a closed panel and took "the category panel is closed" from the default; it now spells that precondition. with_categories() is used by tests about a click opening the panel, so it now shuts it explicitly. Neither is the decision being re-made — the decision is re-made only in the harness, deliberately.

-

#The launch state and the tile ruling in the browser

-

Also merged into harness/tests/contract.spec.js rather than left in the deleted pointer.spec.js — see the note above.

-

Two states, one mechanism: the sidebar is visible with no keypress, and C still hides it. Proved by injection in a real browser: setting el.categories.hidden = false (the page ignoring the flag) fails on toBeHidden; setting the shared default back to false fails on toBeVisible. Different signatures, different halves.

-

Only the second is a guard, and an earlier wording here got that wrong. It claimed "either alone passes against the bug wsaw fixed" — but under the new default, visible-at-launch passes for correct code and for a sidebar drawn unconditionally. The launch assertion states the ruling; the toBeHidden after the toggle is the only thing that still catches wsaw.

-

Note the first C now focuses rather than opens, since the panel is already open — toggle-panel-and-focus closes only when the panel is both open and focused — so the test presses it twice and the hide is the second press.

-

The tile tests were rewritten rather than adjusted, because the previous one asserted the behaviour the ruling reverses; its failure against the new code was itself the injection. The first-click test waits out a timeout before asserting the axis did not move, since a cycle would arrive asynchronously and an instantaneous assertion would pass against a slow one. ---

-

#web/tests/ — the Node suite, and what was injected into it

-

No ### headings here on purpose: --check-evidence resolves a heading to a Rust fn, and a JavaScript test name under one would read as an orphan. These are keyed by file.

-

Every test below was proved able to fail by patching the code it names, checking the patch was on disk, running the suite and reading the failure — never by the absence of an error. Thirteen injections, thirteen failures:

-

What shell.test.js cannot reach, and the PR found out the hard way: it tests createShell in isolation, so it sees the watchdog RULE and never the app.js wiring that calls it. That distinction is now written into context/code/clients.md and guarded in shell.spec.js.

-

shell.test.js — socketUrl stops upgrading ws:// on an https page; composeStatus returns the model fact alone (which fails five tests, because the watchdog's messages are shell facts and vanish with it); fail() stops clearing the timer, so the watchdog pre-empts rather than being disarmed — the bug PR #13 fixed and PR #21 half-lost; extentOf stops subtracting the frame; frameCostOf stops counting the column-group line.

-

grid_plan.test.js — the data-row index is taken from the row's position in rows, so a group header consumes one (the click coordinate bug gotcha 1 is about); the group label is tested for truthiness, so a group legitimately named "" draws as "in no group"; typing is Boolean(model.editing), so an empty edit buffer reads as no editor open.

-

serial.test.js — the chain is replaced by Promise.resolve().then(...), which is what an async listener does: the slow first frame is overtaken. The suite also keeps that fault as a passing test in its own right, asserting the unchained pair really does come back swapped, so the ordering test cannot be green for want of a race.

-

render_surface.test.js — a pub sparkline: String added to RenderModel is reported unpainted; deleting paintTiles reports tiles unpainted; a getElementById that names an id index.html has not got is reported. The first is the shape of the bug the four widgets shared, and the list it checks against currently names doc_name/doc_dirty/doc_stored_as — improvise-5jna, which this test found rather than remembered.

-

bundle/wasm_surface.test.js — client.render_model() renamed to client.render_modal() fails both its tests. It reads the generated .d.ts, so a missing bundle is an assertion failure and never a skip.

-

web/assemble.sh — dropping web/shell.js from the copy list, with the page's own imports as the check. The first version of that guard anchored its sed at ^import, so it could not see the multi-line import that is the only one it exists for, and the injection passed: the guard was written, landed, and blind. That is what the injection was for.

-

#harness/tests/contract.spec.js — eight injections into the served page

-

Patched in harness/serve/app/app.js, which is what the browser loads, so no rebuild stands between the fault and the run. The sidebar ignoring category_panel_open; the tile bar, view panel, formula panel and help overlay each never drawn; click_cell sending a constant row; click_category sending a constant index; and the typed buffer drawn without its caret. All eight fail.

-

The last one did not fail at first: the test asserted the typing class and not the text, so a cell drawn blank passed. The assertion was strengthened until the injection failed, which is the only reason it is worth having — and it is the same lesson as the eleven tests above: a class is a flag, and a flag is not a pixel.

-

#every_advertised_command_answers_in_its_own_mode

-

improvise-rmvx. Gotcha 47 says an upstream key is a black hole, and the_command_names_that_still_need_the_host_are_these is the guard that was supposed to keep the list of them honest. It was built with in_mode(AppMode::Normal), so it only ever asked about Normal-mode bindings: add-record-row (RecordsNormal) and delete-formula-at-cursor (FormulaPanel) were outside its reach entirely and could never have failed it. A named list that silently scopes itself to one mode is a guard that looks broader than it is.

-

This asks it of every mode, and it asks it the only way that is not over-reporting. The naive version resolves every advertised name against an EMPTY ClientState and flags edit-or-drill, commit-cell-edit, create-and-switch-view and page-prev/page-next — but those are handled names refused by STATE, which is correct behaviour and not this bug. So the fixture is furnished: every widget has something in it, a basis is published, and a window is announced, so an Upstream can only mean the name is unhandled.

-

Two things it does NOT do, each because the first attempt did and was wrong. It does not call step with the advertised args: advertised carries None for "any binding of this command", and step's numeric and mode arguments are mandatory, so that reports move-selection, move-panel-cursor and move-tile-cursor as gaps in every mode. And it does not tokenise the rendered hint string: help::advertised is a table of command NAMES, so the cross-reference is exact and anything reading the rendered text is solving a harder problem than exists. The keys come off Keymap::keys_running, which labels_for now shares so "which keys" and "how are they spelled" cannot give two answers.

-

It watches BOTH silent variants, and the first version did not. KeyOutcome has two ways of saying nothing: Upstream, and Ignored — which KeyOutcome::of(vec![]) produces whenever a resolver composes an empty vector. To a user they are the same black hole. The first version asserted resolve(..) == Upstream(event), so three of the six commands this whole change exists to make audible could go completely silent with the entire evidence base green: degrading only add-record-row, delete-formula-at-cursor and view-back to Step::Declined(Vec::new()) left 1397 Rust tests, 19 harness tests and 26 node tests passing, because no browser spec presses those three keys. Found on review of PR #71. The check is now !matches!(.., Upstream(_) | Ignored).

-

The generalisation is worth more than the fix. KeyOutcome::of turning an empty Local into an honest Ignored is good design and it is also a laundering step: every wrong answer that composes nothing arrives wearing the variant that is usually innocent. So a guard's discriminator has to be state — did anything the user can see change — and never the shape of the return value. That is exactly why the +cat injection in a_locally_committed_creating_column_agrees_with_the_host failed (it compares documents and cursors) and the decline injection passed everywhere (nothing compared a screen).

-

Widening the check costs ten more entries, all one legitimate boundary case: move-panel-cursor mirrors MovePanelCursor, which returns nothing when the cursor does not move, so a one-row panel or a cursor on an edge answers Ignored for a reason that is not this test's subject. furnished therefore carries three of everything with all three panel cursors seated at index 1. Note what that rules out: seating them at 0 and calling the Up results expected would have hidden the real defect in the same list as the boundary case.

-

The injection: delete the "toggle-prune-empty" arm from declined. The test fails naming NORMAL: Char('P') and RECORDS: Char('P') — both modes, which is the widening the ticket was about, and a build that ran rather than a compile error. The review's own injection is also kept as a check: degrading the three unpressed declines to empty now fails naming FORMULAS: Char('d'), FORMULAS: Delete, RECORDS: Char('<') and RECORDS: Char('o').

-

What it found that the ticket did not predict: view-back is advertised in RecordsNormal (<, captioned "back"), so it was a seventh instance of the same class. The ticket had it filed under the Normal-mode host list instead, where being upstream is not a defect.

-

The five that remain are listed by name with a reason each. Three of them — commit-export, commit-save, execute-command — are reachable only from a mode declined refuses at the door, so a browser never stands in front of the hint; the other two are improvise-gcvb's records-cell commit, which needs record_col.

-

#a_locally_committed_creating_column_agrees_with_the_host

-

improvise-gcvb. resolve's commit returned None for the whole of records mode, and the reason given was sound for the coordinate and measure columns — a category column renames every measure cell in the row, which needs record_col (gotcha 1). The guard was broader than its reason: a cell at the cursor now projects CellId::Create, and committing one is a single existing ModelCommand over the typed NAME that reads no record_col and writes no cell.

-

Both halves are proved by injection, and the second is the one that matters. Putting the broad is_records() early return back makes the test fail at "the client must answer this key locally". Deleting the CreateKind::Category early return — so +cat advances like any other column — fails with the client in RecordsEditing where the host is in RecordsNormal: the TUI returns straight after a +cat commit with a RebuildLayout and no cursor move, because an inserted column renumbers every index to its right and the advance was computed from the pre-commit layout (gotcha 23). A client that advanced anyway would land one column off, silently.

-

both_ends compares the document as .improv text, the mode, the status and every cursor, so neither half can pass by agreeing on the wrong thing. The fixture drives both hosts into records mode with the R key rather than hand-building a _Records view, because twin_of copies only the session slice and a twin without the view would refuse the commit for the wrong reason.

-

#every_minibuffer_mode_paints_what_is_being_typed

-

improvise-fdt6. commit-buffer formula already resolved on the client — that half of the ticket was closed by an earlier wave — so the browser could enter FormulaEdit, type a formula and commit it. What did not exist was any way to SEE it: RenderModel::editing is populated only when mode.is_editing(), which is the two cell-editing modes, and it reads the "edit" buffer by name. Every character typed into FormulaEdit, CategoryAdd, ItemAdd, SavePrompt, ExportPrompt or CommandMode went into a buffer nothing painted. The mode opened, the user typed, and the screen did not move — which is exactly the dead-room shape the ticket names, arrived at from the rendering side rather than the resolution side.

-

The value is read with improvise_protocol::read_buffer rather than buffers.get, so the "search" special case (gotcha 11) keeps its one home.

-

The injection: .filter(|_| state.view.mode.is_editing()) on the new field, which is the pre-fix behaviour exactly. It fails naming "formula".

-

And the static check was not enough, which is the part worth keeping. web/tests/render_surface.test.js asserts that every pub field of RenderModel appears as model.<field> somewhere in app.js. It passed while the page was still broken: the string model.minibuffer was inside paintMinibuffer, and paintMinibuffer was never called and el.minibuffer was never looked up. A field can be mentioned by a function nothing invokes. harness/tests/keys.spec.js caught it on the first run, in the browser, which is the whole argument for that directory existing (gotcha 48).

-

#nothing_declined_is_also_composed

-

step is declined then composed, and until this test the claim that the two lists cannot overlap was a doc comment. The dangerous direction is not a typo: it is a future ticket implementing view-back (or toggle-prune-empty, which is one projection field away) in composed and not thinking to delete its declined arm. The browser then goes on saying "not available in the browser yet" while the working code sits unreachable, and nothing notices — the classic shape of a check passing while the thing it names is broken. Raised on review of PR #71.

-

It is derived from declined itself, not from a list beside it: it walks every (name, args) bound in every mode's keymap — parents and prefix sub-keymaps included, sequence steps included — and for each one declined answers, asserts composed does not. A seventh decline is covered without coming here, which is the property a hand-written list could not have.

-

The injection: add "view-back" => Some(vec![Command::SwitchView(..)]) to composed. It fails naming NORMAL: view-back [] and RECORDS: view-back [].

-

It also found a real overlap on its first run. enter-mode is answered by declined for the three stranding modes and by composed for every mode, so composed's arm would have opened SavePrompt, ExportPrompt or CommandMode if declined were ever bypassed. Rather than moving the check, both halves now hold the rule — composed returns None for a stranded mode — so the failure direction if one is removed is the pre-PR Upstream, not a dead room. ---

-

#harness/tests/help.spec.js — the overlay, asserted as words

-

PR #61 deleted "? opens help, and its rows are the shared ones" and nothing replaced it, so paintHelp, help, help_page and help_titles reached no assertion at all (improvise-78v6). What contract.spec.js carried in its place said only that a dt was VISIBLE — and an empty dt is a visible dt (improvise-igxp), which is the same shape as the caret bug above: a row is a frame, and a frame is not a word.

-

The injection both tickets name: blank keys.textContent in paintHelp, patched in harness/serve/app/app.js. Before this work it left all three contract tests passing. It now fails both tests in this file (the Welcome banner reads empty; the advertised key reads empty) and contract.spec.js, whose two help lines were strengthened from toBeVisible to the banner's text and the first dt.help-keys's text.

-

What the pairs helper is for. The overlay is a <dl>, and the association between a key row and its description is document order — dt then dd. Read as two lists they zip wrong, because a prose row emits no dd at all (deliberately: an empty one would leave a blank grid cell). Welcome is prose end to end, so dd count zero there is an assertion about that rule and not a coincidence.

-

#"a key the overlay advertises does what the overlay says it does"

-

The assertions above are still about a string on a screen; this one asks whether the string is true. The key for Jump to last row is read OUT OF THE DOM, the overlay closed, the key pressed, and the selection asserted onto the last row. A page drawing a plausible key it does not have — what a hand-spelled table decays into — fails here, and the spec holds no second copy of the keymap.

-

It presses the FIRST key the row names because a row is a compaction of every binding that reaches the command (h j k l ← ↑ → ↓ is eight).

-

#harness/tests/grouped.spec.js — the first browser run of improvise-anr9

-

web/demo.improv has no groups and one category per visible axis, deliberately (gotcha 46), so the multi-level header, the group span, the collapse indicator and the inline group row — exactly the code improvise-anr9 added — had never been rendered anywhere a test could see (improvise-plcx). The fixture is harness/fixtures/grouped.improv, served beside the shipped demo by harness/build.sh, and the shipped document is unchanged.

-

A missing seed is not an error: web/worker.js answers a fetch that 404s with an empty model. So a typo in the filename yields a page that draws a grid, and "a grid is drawn" would pass on it. Every assertion here is about labels the fixture really carries.

-

openGrouped waits with retrying assertions and the tests then read once. The head and the row labels are read in a single evaluate each, because what these tests assert is the RELATION between the lines — and a one-shot read cannot wait. The grid is painted from the first projection and reframed by the viewport the page then announces, so the helper gates on the head's shape, its first group and the first row's innermost label before anything is read.

-

Five injections, five distinct failures, patched in harness/serve/ so no rebuild stands between the fault and the run:

-
    -
  • gridPlan slash-joins the level tuples into one line (the exact defect anr9 fixed) — fails all three, because openGrouped waits on the head's shape before any test reads it;
  • -
  • th.colSpan = 1 on every column group header — fails the span test alone;
  • -
  • a stray tr.insertCell() on the inline group row — fails the group-row test alone, which is the DOM half of RenderRow::Group carrying no cells;
  • -
  • indicator(false) hard-coded — fails on ▶ Upland while ▼ Inland still passes, which is why the fixture has one collapsed group and one open one;
  • -
  • collapsed: Region/Upland removed from the served document — fails on the rows that reappear, so the assertions see the collapse and not just the glyph.
  • -
-

A bug this fixture surfaced rather than predicted. Coastal is the row frame's FIRST entry and its header is never drawn: the spec asserts two group rows where the document has three, with improvise-0o6x named on the line. That count is a record of an open bug, not an endorsement — when 0o6x lands it becomes 3 and this line is where the change must be argued.

-

#harness/tests/landing.spec.js — the front door

-

docs/index.html is copied to the site root by web/assemble.sh, so the harness has been serving it all along with nothing asserted about it (improvise-bgtf).

-

Counting the four .ap-player elements proves nothing, and that is the whole reason this is a spec rather than a one-liner: autoPlay is false, so asciinema-player fetches no cast until play is pressed. A page whose every cast path 404s constructs four players, throws no page error, and looks identical to a working one. Injected while the page was being written (./casts/ -> ./cats/) the construction-only check passed on the broken page.

-

Re-injected into harness/serve/index.html now, the spec fails at the first player: .ap-line elements exist only once there is a cast to lay out. The spec also asserts, BEFORE pressing anything, that no .cast has been requested — without which the network evidence below could be satisfied by a page that fetched them eagerly, which is not the page we ship.

-

Two assertions where one looks sufficient: the rendering says a cast played, and the recorded responses say four distinct casts answered 200. Three players showing the same recording satisfies the first and fails the second.

-

"the front door reaches the app it advertises" follows the footer link rather than asserting its text, because whether ./app/ resolves is a fact about the assembled tree. Injecting href="./aplication/" fails it.

-

#harness/tests/contract.spec.js — the ninth injection, and a lost guard

-

The lost guard. improvise-78v6 asks for the test-name diff over PR #61's range, and it finds a fourth loss beyond the help test: "a click on an item row filters, as Enter does". contract.spec.js asserts the CATEGORY arm of click_category_row (the row expands) and nothing asserts the ITEM arm, whose rule is "do what Enter does here" — filter. Only #status distinguishes them, so a click wired to the wrong arm passed the whole file. Restored inside the click test, and proved by injecting click_category(row.is_category ? index : 0), which fails that line alone.

-

The diff that finds this is two commands, and is worth running on any PR whose subject is a lost guard:

-
M=$(gh pr view 61 --json mergeCommit -q .mergeCommit.oid)
-for r in "$M^1" "$M"; do
-  git ls-tree -r --name-only "$r" -- harness/tests web/tests |
-    while read -r f; do git show "$r:$f" | grep -oE 'test\("[^"]*"'; done
-done
-

The other two losses are deliberate: pointer.spec.js's sidebar test was superseded by the open-at-launch ruling, and the widgets file was merged into this one.

-

#the_keys_that_compose_a_switch_view_are_these

-

improvise-lltt is deferred on a premise, not a judgement: its own note says it is "unreachable today ONLY because no shipped client emits Command::SwitchView", and asks for a guard asserting exactly that, so the silent failure becomes loud the moment the premise stops holding.

-

The guard failed on its first run. Two live paths in this client's own resolver (resolve.rs): switch-view-at-cursor and create-and-switch-view. The other half was already live — the same resolver composes ModelCommand::TransposeAxes. So the scenario lltt describes is reachable today from the shipped view panel, and the ticket was escalated P2 → P1 rather than fixed here, the honest fix being the protocol change it describes.

-

It is an assert_eq against those two names, not an assert-empty, and the list is not an exemption list. It pins what is true so a third live path fails, and its message says so — because a reader who found this passing would otherwise conclude the premise holds. When lltt is fixed the list empties and this test goes with it.

-

Why the enumeration and not a read: the bug was already written into a passing test's comment elsewhere ("Aimed at the host's own view… HideItem writes view_mut()") and had been read as a fixture note for as long as it had been there.

-

#"a click moves the selection from an editing state too"

-

improvise-0x9u, and the fault was injected by running this assertion against the bundle as it stood: cells.nth(4).locator("input") came back 0, with the selection still on cell 0, exactly as the bug report measured in Chromium.

-

Two things about it are worth more than the assertion. First, what it does not do: it never presses Escape between the two clicks. The sibling test does, and that single line is why the property it names ("a click moves the selection") was asserted for a whole wave while the neighbouring state was broken — the click path from an editing page was never exercised.

-

Second, the fix's own first attempt failed this same test, which is the part to remember. Moving the handler to mousedown is necessary and not sufficient: close still repainted, and removing a focused <input> fires blur synchronously, so close rebuilt the table from inside itself and detached the <td> the handler was about to open an editor on. The assertion could not tell that apart from the original bug — same symptom, different cause — so it is the debug run rather than the test that separated them. close therefore no longer repaints, and the blur arm checks closed before dismissing.

-

Third — and this is the edit the repair nearly shipped unguarded — the fix is three edits, not two, and the third is the one with the most careful comment: close restores td.textContent (the drawn value) instead of seed (the stored one), because close no longer repaints and repaint() early-returns for as long as editing is set. Reverting that one line to td.replaceChildren(original) left the whole suite green, and the defect was on the front page: web/demo.improv carries format: ,.0 over 18400, and this test's own two clicks — no Escape between them — leave cell 0 reading 18400 where the page had drawn 18,400, for as long as the second editor stays open. The assertion added for it is toHaveText("18,400") after the second click, and the injection is exactly that revert: it fails with unexpected value "18400", naming this test and no other.

-

The pattern is worth carrying past this entry. A repair of N edits whose N−1 guarded ones pass is not a guarded repair, and the unguarded edit is reliably the one the author understood best — which is why it carries the clearest comment and why it felt covered. For each distinct edit, name the test that fails when only that edit is reverted; a line with a comment and no answer to that question is the line to inject.

-

#"the widgets the demo document fills are drawn, in the state that fills them"

-

The #doc-name row is improvise-5jna, and the reason it belongs in a browser test rather than in render_surface.test.js is that the latter is a substring search over the page's source: doc_name mentioned by a function nothing calls reads as painted. That is how model.minibuffer once shipped dead, and how z754's three title-bar fields sat projected and unpainted for a wave while the static check was green. Proved by adding the assertion first: element(s) not found for #doc-name.

-

The [+] marker is deliberately not asserted here. The standalone deployment persists on every accepted batch and effect::Save clears the flag in the same round trip (gotcha 45), so the marker is transient in a browser and any timing assertion over it would be flaky rather than discriminating. Its spelling — the order name (stored_as) [+], the null stored_as writing no empty parens — is web/tests/shell.test.js, over documentTitle directly.

-

#"a selection driven below the fold scrolls itself into view"

-

improvise-jq8l, and the expensive half was the fixture, not the assertion. Neither shipped document can reach the state: web/demo.improv is four rows and harness/fixtures/grouped.improv is eight, and a probe measured both at scrollHeight === clientHeight in a 484px box — nothing to scroll, so the assertion would have passed against a page that never scrolls. That is the vacuity this entry exists to record, and it is why the spec asserts scrollHeight - clientHeight > 0 before it presses a key.

-

harness/fixtures/tall.improv is thirty rows in three groups, which overflows for a reason worth stating: ROW_PX is 26 and a real row measures 28.6px, and inline group rows are not charged for at all, so the page renders more rows than its own box can show. Measured against the unfixed page: 105px of overflow, the selected cell 96px below the visible bottom, scrollTop still 0 after twenty-five ArrowDowns.

-

Both directions are asserted, and neither alone is enough: the cell being inside #grid's rect would also hold if the selection had never moved, and scrollTop > 0 would also hold if the page scrolled to the wrong place.

-

#"the casts are legible on a phone, and the page still does not scroll sideways"

-

improvise-o31u, and the instrument is the finding. getBoundingClientRect reports a height for a content-visibility-hidden child, and heading text overflows its box without widening it, so neither "the element is present" nor a box measurement can see this defect at all. What a reader sees is the computed font size, and that is what is asserted.

-

Measured against the unfixed page, and it reproduces the ticket's figure to five digits: pivot.cast renders its terminal at 4.83258px on a 390px viewport. The arithmetic is not subtle — the player is told cols: 120, the container is ~356px inside the page's 16px gutters, and the player DERIVES its font size from the width it is given. After the fix: 11.9457px.

-

Two assertions guard the trade this fix must not make. The page's own scrollWidth - clientWidth must stay 0 — a document that scrolls sideways on a phone is a worse bug than one unreadable section — and #cast-pivot must be the thing that scrolls, which is what separates "contained the overflow" from "removed the overflow by shrinking something else".

-

#"a desktop viewport is unchanged by the phone fix"

-

The negative arm, and it is forward-looking: no fault available today makes it fail. The entry previously named two mechanisms and both were measured, at a 1280px viewport, against the shipped page:

-
    -
  • @media (max-width: 900px) → @media all: this test passes (13.1131px, scrollWidth === clientWidth).
  • -
  • min-width: 880px moved from .player > * onto .player: this test passes; the phone arm fails instead, on scrolls.page === 0.
  • -
-

The arithmetic is why, and it is worth stating rather than re-deriving: at 1280px #cast-pivot measures clientWidth === scrollWidth === 966, so an 880px floor is inert at desktop wherever it is placed. The floor would have to exceed the ~966px the player actually gets — or the content column would have to narrow — before either assertion here could move. That is a real future fault and the reason to keep the test: it pins 13.11px and "nothing to scroll" against a floor raised past the player's own width. It is not a reason to claim the arm currently discriminates between the two placements, because it does not.

-

Note also that the phone arm is stronger than this entry used to credit it with: the misplaced-min-width fault, attributed here to the desktop arm, is caught there. scrolls.page === 0 is the assertion that separates "contained the overflow" from "moved it onto the document".

-

#"no cast carries the recording machine's shell, clock or geometry"

-

improvise-2oxt, and both clauses were injected separately, which matters because the first would otherwise mask the second. Serving the pre-2oxt pivot.cast fails on env — Received: {"SHELL": "/bin/zsh", "TERM": "screen-256color"} — and stops there. Stripping env and timestamp from that same header and serving the body unchanged then fails on pivot.cast sets a window title, which is the clause that actually watches for the leak: an OSC window-title sequence, emitted by a shell once per prompt and carrying the cwd, and emitted by improvise never.

-

Asserted over the cast file rather than over the rendering, and that is the finding. A player draws a shell prompt as ordinary terminal text, so a screenshot or a toContainText assertion would have to know what the hostname and home directory had been — a check that can only be written by someone who has already seen the leak. The file-level signature can be written by anyone.

-

What no assertion here reaches, and how it was checked instead: whether each cast SHOWS what its section claims. That is not a property of the bytes. It was verified by replaying each recording through a small ANSI reconstruction and reading the frames as text, which found two scenarios that recorded cleanly and demonstrated nothing — a drill into a cell with no records behind it, and a c pressed on a category already on the column axis. Both are true frames about the wrong thing, which is the failure mode this repo keeps meeting, and neither was visible from the keystroke list.

-
-

#improvise-ws-server — the Origin check (improvise-kc7n)

-

The bug: grep -rni origin crates/improvise-ws-server/src/ returned nothing. The server inspected no Origin header and accepted every handshake, and a websocket handshake is exempt from the same-origin policy — no CORS preflight — so any page a user visited could open a socket from that user's own allowed address and read and edit every document served. An address check cannot see it; the argument is context/code/clients.md §Origin.

-

Five edits, five guards. Each was reverted alone and the named test failed; nothing here is covered only by a comment. Review added a sixth — see two_origins_state_none_this_policy_can_answer_for.

-

#a_handshake_from_a_foreign_origin_is_refused

-

The reproduction, written first and watched fail on 2afbf37: a handshake carrying Origin: https://evil.example connected, and the test's connect_async(...).err() was None. After the fix the upgrade is refused with a 403 and the assertion is on the status, not merely on "an error happened" — a refused upgrade and a dropped connection are different outcomes and only one of them is the fix.

-

Injection: replacing the handshake callback's admitted(&origins, req) with Ok(()) — the transport collecting a policy it never asks — fails this and a_named_origin_reaches_the_document, and nothing else in the workspace.

-

#a_handshake_with_no_origin_is_accepted

-

The case most likely to be wrong later, so it is named rather than left to a match arm. Non-browser clients send no Origin: curl, a native client, this file's own other tests. Injection: flipping OriginPolicy::permits's None arm to false fails this, an_absent_origin_is_allowed, the_handshake_refuses_what_the_policy_refuses, and both pre-existing socket tests — which is the point: the repo's own instrument is the non-browser client this arm is about.

-

#a_handshake_from_a_loopback_page_is_accepted

-

The shipped local recipe — the page served by python3 -m http.server 8080, the socket on another loopback port — must keep working with no flag, or the default is a different bug from the one being fixed. Injection: making is_loopback return false fails this and a_loopback_page_is_allowed_by_default and leaves the refusal tests green, which separates "allows loopback" from "allows everything".

-

#a_named_origin_reaches_the_document

-

--allow-origin end to end over a real socket, plus the negative arm in the same test: widening for one page does not widen for its neighbours. Without that second assertion the test passes against a policy that permits everything, which is the fault E1a injects.

-

#allow_origin_widens_the_policy_and_nothing_else_does

-

The wiring, which no socket test can reach: the other tests build an OriginPolicy directly, so a flag collected by clap and dropped on the floor is invisible to every one of them. This drives Args::parse_from and asserts both directions — bare refuses the deployed https origin and still allows loopback; widened allows the named origin and not its neighbours. Injection: making Args::origin_policy return OriginPolicy::default() fails this test alone.

-

#two_origins_state_none_this_policy_can_answer_for

-

Found by review rather than by the ticket. HeaderMap::get is first-wins, and the deployment this README recommends puts a reverse proxy in front of the socket — a proxy that appends Origin rather than setting it leaves two, and reading the first lets the second be anything at all. No browser sends two, so two is not a state any policy can answer for and the handshake is refused. Injection: restoring the first-wins read ((Some(value), Some(_)) answering from value) fails this test alone, across the whole workspace. The test also asserts the allowed origin by itself is still allowed, so the refusal is demonstrably the second header rather than the first.

-

#a_refusal_is_a_forbidden_response

-

The refusal has to be a refused upgrade, and the socket test asserts the Error::Http kind and the status rather than matching "403" in a Display string: a dropped connection and a refused handshake are different outcomes and only one of them is this fix. Injection: changing forbidden() to UNAUTHORIZED fails this and a_handshake_from_a_foreign_origin_is_refused, which is what shows the socket test reads the status rather than merely observing that connecting failed.

-

What the signature covers instead of a test: transport::serve takes the policy, so a host cannot forget to pass one — doctrine(an_invariant_is_made_ unrepresentable_not_validated). What remains uncovered and is stated rather than guarded: a proxy that strips Origin turns the check off, which is why the proxy's own Origin allow-list stays in README.md as defence in depth.

-

#the_fixtures_basis_tracks_a_host_whose_layout_has_moved

-

improvise-1l9o. furnished_client and client_ready_to_edit built their BasisCache by walking the client's own grid for Record cells and calling basis::published(0, 0, records) with every version hard-coded to zero — a small re-implementation of project.rs::basis_update's record selection, in a file whose entire purpose is to rule out re-implementations. Both now take the Projection::Basis out of a real Sessions::connect snapshot.

-

The reason the ticket calls it a nit is the reason it needed a test: a fresh App really is at generation 0, layout 0 and every record at version 0, so the fabrication and the publication are indistinguishable on every existing fixture. All 23 lockstep tests pass under either. So the change alone is structural and untested by construction, and this test is what makes it observable: it moves a tier first — a HideItem through apply_on, which observe_reshape sees as a shape change — and then asserts the fixture's claim names the host's current tiers.

-

Note SetNumberFormat does not work here and was the first attempt. It declares EffectKind::Model(ModelChange::LayoutGeometry), but layout_version is observed from view_shapes rather than read off the declaration (improvise-pdvm), and a number format is not part of a view's shape hash. A command being classified LayoutGeometry is therefore not a promise that it moves layout_version — which is the observed-not-declared design working as intended, and easy to misread as a bug.

-

Fault injected 2026-09-21: re-fabricating the basis inside published_basis (published(0, 0, real_records) — keeping the real record set, so only the two document tiers are forged) fails this test with left: (0, 0) against right: (0, 1), and fails nothing else in the workspace.

-

One residual, found in self-review and not closed. published_basis asks Sessions::connect for a window of window.0 x window.1, and a fresh session sits at offsets zero; client_of builds its grid at the cursor's offsets. Every fixture here has its cursor at the origin, so the two windows coincide and the published record set is the one the client's grid names. A future fixture that scrolls before building its client would get a basis naming records its grid does not show — which claim would then silently omit, and Rejection::RecordUnstated is what the server answers with. The fixtures no longer fabricate, which was the ticket; they do still assume a cursor at the origin.

-

#leaving_a_panel_in_records_mode_lands_where_the_host_lands

-

improvise-e5pf. AppMode::Normal is settled by the host against the layout the batch ends on; the wire carries Command::ChangeMode(AppMode::Normal) and reduce_view folds it verbatim. So a client that opened a panel from records mode and pressed Esc landed in Normal while its host landed in RecordsNormal — and RecordsNormal has its own keymap, so the divergence compounds on the next keypress.

-

The reachable path, which the ticket did not name: Tab in RecordsNormal resolves locally to ChangeMode(CategoryPanel), and Esc there resolves to ChangeMode(Normal). c/f/v are Ignored in RecordsNormal and R itself is a stranding decline, so the panel keys a first draft of this test used never separated anything — the test panicked on "the client must answer this key itself" rather than on the drift.

-

Why lockstep could not see it before. Its records coverage (typing_at, records_pair) set host.view_state.mode by assignment and seeded the client with client.view = app.view_state.clone(), so it started where the mode and the layout already agree, and every divergence here requires them to disagree — doctrine(a_guard_seeded_on_agreement_cannot_detect_drift). in_lockstep drives the key on both ends and then refreshes the client's grid from a real projection, never from the host's slice, which is the only seeding that can show the two apart.

-

Fault injected: resolve::changed_mode reduced to Command::ChangeMode(mode). The test fails on close: the client and the host disagree about the mode, Normal against RecordsNormal.

-

Two existing resolve.rs fixtures had the same shape and had to be corrected with the fix: furnished and a_records_mode_commit_is_the_hosts put the client in a records mode over a non-records grid, which is a state no host can produce. Both now project GridUpdate::Records as well. That they had to change is the evidence that the substitution was load-bearing rather than incidental.

-

#a_records_commit_is_declined_off_the_grid_and_not_off_the_mode

-

improvise-e5pf's damage half. resolve::commit is written to decline a records commit it cannot compose (it needs record_col, gotcha 1). It asked state.view.mode.is_records(); with the mode drifted to plain Editing over a records grid it did not decline, and composed typed_cell_command plus a Basis claim against a grid whose columns are [coords…, +cat, measures…, +]. A wrong write, strictly worse than the black hole it exists to produce.

-

The test states the drift rather than reaching it — reaching it takes the key sequence the test above drives — and asserts no Command::Model is composed.

-

The control is the point. A first version of this test passed against the broken code, because client_of publishes no basis and BasisCache::claim returns None until one arrives, so commit returned None for a reason that had nothing to do with records mode: doctrine(an_injection_that_does_not_fail_names_an_untested_path), caught by injecting the old spelling and watching the test still pass. records_client now publishes a basis naming every record the grid projects, and the test carries a positive control — the same Editing mode over a regular grid must still compose a write — so "this client never writes" cannot pass it.

-

Fault injected: the decline restored to state.view.mode.is_records(). The test fails on a records commit this client cannot compose was composed anyway.

-

#the_normal_pair_is_settled_in_both_directions

-

App::settle_normal_mode settles both Normal and RecordsNormal against the layout, so a RecordsNormal over an ordinary grid becomes Normal. The first version of the client's settling mapped only Normal → RecordsNormal, which left the mirror-image drift live. Found in review of this branch.

-

The test now aims at improvise_protocol::settled_mode, which is the one rule all three ends call — ClientState::fold, ClientSession::fold and App::settle_normal_mode.

-

Fault injected: the (Normal | RecordsNormal, false) arm narrowed to (Normal, false). The case records=false, holding RecordsNormal fails. The fourth row and the pass-through cases are the negative arms — without them a rule that answered RecordsNormal always, or Normal always, would pass, and the pass-through loop is what pins that Editing/RecordsEditing are not settled (the reason a records decision asks the grid and never the mode).

-

#switching_between_a_records_view_and_a_pivot_view_settles_both_ways

-

The P1 ChatGPT Codex found on PR #98, and the more useful half is why the first fix missed it. That fix settled the mode at each composition site (resolve::changed_mode), against the grid the client held when it resolved the key. switch_view_at composes [SwitchView(target), ChangeMode(Normal)], so it settled against the outgoing view: records → pivot emitted RecordsNormal and pivot → records emitted Normal, recreating the very drift the PR closed.

-

The hand-back had predicted this as follow-up 3 and concluded it was unreachable because R is a stranding decline in the browser. That reasoning is right about the toggle route and wrong about this one: records-ness is auto-detected from the axes (gotcha 1), so a view switch changes it with nothing in the batch saying "records". One door was enumerated and there were two — doctrine(an_unreachability_claim_is_only_as_wide_as_the_routes_enumerated). Codex had flagged the same path on PR #91, where the host side was fixed and the client side was not, so it was tracking one defect across three PRs.

-

The remedy is general rather than a patch to switch_view_at: changed_mode is deleted, every site emits the plain AppMode::Normal, and ClientState::fold settles after every fold against the grid it holds. Route-independent, so no enumeration of composition sites is load-bearing any more.

-

The fixture drives the real path: a Host in records mode, a session connected (which opens the document's default view, gotcha 29 — the first draft asserted the opposite and failed its own precondition), the composed pair folded locally and sent as a ClientFrame::Commands, and every reply frame delivered in arrival order. deliver takes the whole reply rather than a hand-picked projection.

-

The composed ChangeMode carries the STALE answer deliberately — settled_mode(&Normal, client.grid.records) off the outgoing grid — because that is what switch_view_at really emitted, and composing the friendly value instead would make the records → pivot direction pass under the fault.

-

Fault injected: ClientState::settle_mode reduced to a no-op. Both directions fail, each proved separately because the loop stops at the first:

-
    -
  • pivot → records: _Records: the client settled against the view it LEFT, Normal against RecordsNormal.
  • -
  • records → pivot, reached by relaxing the first iteration's expectation under the same injection: Default: the client settled against the view it LEFT, RecordsNormal against Normal.
  • -
-

Records-ness and the mode are asserted as two separate facts rather than one derived from the other — a first version computed the records precondition from the mode expectation, so relaxing the mode silently relaxed the precondition too and the test failed for the wrong reason.

-

It has a host-side twin, and the two are a pair rather than a duplicate. improvise-4dpj landed switching_views_across_records_ness_settles_against_the_view_switched_to in ui/app.rs while this was being written: the same defect, the same batch ([SwitchView(to), ChangeMode(Normal)]), the same crossing in both directions, and independently the same phrase in its message — "settled against the view it LEFT". One is the host settling after the batch against the layout it ends on; this one is a client settling after the fold against the grid it holds. Neither is redundant: nothing in the projection stream carries the host's ViewState (gotcha 47), so a host-side guard cannot see a client's mode and the reverse. Delete either and that side goes unwatched.

-

#the_mode_a_switch_lands_in_is_the_one_a_host_on_that_view_holds

-

The lockstep half of the entry above: it asserts the host itself answers differently per view (Normal on the pivot, RecordsNormal on the records view). Without it, "the client settles to X" is a claim about the client alone — a host that answered Normal everywhere would make the switch test's expectations arbitrary rather than lockstep.

-
-
- - diff --git a/site/docs/test-evidence/command.html b/site/docs/test-evidence/command.html deleted file mode 100644 index f6de07e2..00000000 --- a/site/docs/test-evidence/command.html +++ /dev/null @@ -1,927 +0,0 @@ - - - - - -improvise-command — improvise docs - - - -
- -
-

Source: context/test-evidence/command.md

On this page

improvise-command — test evidence

-

Why each of these tests exists, what bug it reproduced, and what fault was injected to prove it can fail. Keyed by exact function name; a rename orphans the entry, and scripts/check-comment-runs.py --check-evidence reports that (improvise-9c8c, design-principles §7).

-

Entries cover tests and the helpers whose shape is the evidence.

-
-

#ui/reduce.rs

-

#a_view_slice_command_does_not_move_the_authoritative_view_state

-

This test used to assert the opposite, under the name view_slice_commands_land_in_the_authoritative_view_state, and it passed against a path no host walks (improvise-s17o): apply_command's fallback arm folded a wire view command into app.view_state, the authoritative slice. Nothing in production reached it — Sessions::apply_checked folds a view command into the asking client's mirror — so the behaviour it pinned was wrong and safe only because it was dead. The model is shared, the UI is not (principle 5, gotcha 36); a client's SetStatus must not move the host's status line.

-

The fixture writes "host" into the status line first, so the assertion distinguishes "the fold was removed" from "the field happened to be empty" — with the default empty string, deleting the fold and leaving it in place look the same for SetStatus. Restoring reduce_view(&mut app.view_state, other) fails on the first field.

-

#Helper — checked

-

Not a test — the fixture every reduce.rs test below reaches the door through.

-

For the tests whose subject is not staleness: a command the current state accepts, which every command built by caught_up is by construction. The tests whose subject is staleness call Checked::against themselves and assert on the Rejection, so neither kind can pass by accident through the other's convenience.

-

#switch_view_moves_the_client_and_leaves_the_documents_default

-

The successor to switch_view_moves_the_document_and_the_client_together, which asserted the pre-improvise-jytm lockstep. Which view a client is on is now that client's; Workbook::active_view is the default a fresh client opens and what .improv writes. The fault it rules out is the old behaviour returning by inertia — any writer that moves the workbook's copy from a client's switch fails it.

-

Fault injected 2026-09-20: restoring App::switch_to_view's workbook.switch_view(name) call fails this and the_documents_default_view_outlives_this_clients_switches.

-

#a_wire_view_switch_records_history_the_way_a_key_switch_does

-

A wire-driven view switch owes the same history a key-driven one leaves behind.

-

effect::ViewBack pops view_state.view_back_stack, and the only thing that ever pushes onto it is effect::SwitchView. While apply_command performed the switch itself — calling App::switch_to_view directly rather than running the effect — the stack stayed empty, so a client that switched over the wire could not go back, though the same two keypresses could. Wave 4 is what made that reachable: before ModelCommand there was no wire way to ask.

-

Nothing else in the suite could see it. switch_view_moves_the_client_and_leaves_the_documents_default checks where a switch landed; this checks what it recorded.

-

Bug demonstrated 2026-09-19 (Copilot, PR #11): the ViewBack left the document on "Second". Since improvise-jytm the step back is driven through effect::ViewBack rather than ModelCommand::ViewBack, which left the wire with that ticket — the stack it pops is one client's history, so a nullary wire command could not address it (gotcha 38). What the test asserts is unchanged.

-

#switching_views_projects_the_axis_widgets_only

-

Switching views changes what the axis-bearing widgets read, so they are told; the list of views itself did not change, so it is not.

-

#an_inbound_projection_is_ignored

-

Projections travel one way. A client that sends one back must not be able to write it into the server's state.

-

#a_keypress_that_changes_the_model_projects_it

-

The key path is the model's door, and it reports the widgets it touched so a host can project them.

-

#a_view_only_keypress_projects_nothing

-

A keypress that moves only this client's cursor reaches no other client (gotcha 3).

-

#every_model_command_names_an_effect_that_declares_a_model_change

-

The derivation, stated as a check. This is what "derived, not invented" means operationally (doctrine(derive_the_enum_from_the_effects)). A variant pointed at a ViewOnly effect would be a wire message that changed the document for the client that sent it and for nobody else — Dirty::of would report NOTHING, and no projection would ever correct the others (gotcha 35).

-

#Helper — model_app

-

Not a test — the fixture the three every_model_command walks run against.

-

app() with a cell actually stored, so a surrogate for it resolves through the index rather than only through the layout. The walks over every_model_command run against this: a SetCell whose record cannot be resolved produces no effects, and every one of those tests asserts on the effects, so the fixture has to name something real or they would all fail for a reason that is not theirs.

-

#no_model_command_commits_another_clients_session_local_state

-

improvise-3x3u: the wire vocabulary is the addressable model effects, not all of them.

-

Sessions::apply routes a Command::Model with no SessionId, which is right exactly when the change is addressed to no one session (gotcha 36). ApplyAndClearDrill and AcceptWizardModel are addressed to precisely one, and it is not the sender: an unaddressed ApplyAndClearDrill commits another client's private typing (improvise-tom1) and an unaddressed AcceptWizardModel swaps the document out of a dialogue the sender never opened. Both were demonstrated against Sessions::apply before the variants were removed.

-

Re-adding either variant means making it addressed — carrying a SessionId, with the staging map moved onto the session — not restoring the nullary form this test forbids.

-

SESSION_LOCAL_EFFECTS is the list of the two effect names.

-

#the_session_local_effects_are_real_model_effects

-

The guard above is only worth having while the effects it names still exist and are still model changes — otherwise it would pass by naming nothing (doctrine(check_the_checker)).

-

#the_effect_name_reader_reads_all_three_debug_shapes

-

The reader above is itself a check, so it gets the injection too (doctrine(check_the_checker)): one effect of each Debug shape, because the leading identifier is followed by a different delimiter in each — nothing, (, and {.

-

#Helper — effect_type_name

-

Not a test — the reader that test injects against, used by the two guards above.

-

The type name of an effect, read off the leading identifier of its derived Debug. Effect is a trait object, so std::any::type_name is not reachable through it; every effect derives Debug, and a derived Debug opens with the type's own name for a unit struct, a tuple struct and a braced struct alike. So the name is already on the value — this only reads it back.

-

#every_model_command_names_the_effect_of_the_same_name

-

The mapping is name-for-name by construction, so state that: every ModelCommand variant names the effect that carries its own name.

-

This is the check that separates an effect from its inverse. every_model_command_names_an_effect_that_declares_a_model_change cannot: HideItem and ShowItem, AddItem and RemoveItem, CreateView and DeleteView, SetCell and ClearCell are each two effects of the same EffectKind, so a swapped pair clears that bar while inverting what the wire message does. And a_model_command_lands_where_its_keypress_lands cannot reach them either — most variants have no single keypress to compare against, so it drives three of twenty-eight.

-

Proven by injection 2026-09-19: HideItem → effect::ShowItem, CreateView → effect::DeleteView and AddItem → effect::RemoveItem all left cargo test --workspace at 918 passing, 0 failing. This test fails on each.

-

The name-for-name rule is a design constraint, not a coincidence, and it is what makes the derivation checkable at all (doctrine(derive_the_enum_from_the_effects)): a variant whose effect must be named something else is a variant that stopped being derived, and it should fail here and be argued about rather than pass quietly.

-

#the_enumeration_names_each_variant_once

-

The every_model_command list covers the enum once. A duplicated entry would leave a variant untested while the count still looked right.

-

#Helper — every_model_command

-

Not a test — the enumeration that test, and the three walks above it, cover.

-

One value per ModelCommand variant. The enum is the derived set, so a test that walks "the set" has to walk all of it. variant_name is the completeness guard: it matches exhaustively, so a variant added to the protocol fails the build here until it is listed in this vector as well.

-

#a_model_command_lands_where_its_keypress_lands

-

The point of the whole ticket: a model Command applied authoritatively leaves the document exactly where the keypress bound to the same mutation leaves it. That equivalence is what lets the two entry points share one door.

-

The Dirty is compared too, because a host with connected clients projects off it: a command that produced the right document and the wrong Dirty would leave every other client stale.

-

Its pivoted() fixture has two categories on different axes, because the one-category fixture cannot tell a transpose from a cycle: with only Region on Row, swapping the axes and rotating Row→Column land on the same view, and an equivalence test that cannot distinguish the wrong effect from the right one proves nothing (doctrine(check_the_checker)).

-

#a_stale_wire_model_command_cannot_be_applied

-

A wire Command::Model cannot reach the document without its basis being checked (ADR 001, improvise-ondj).

-

Bug demonstrated 2026-09-19: reduce_full and apply_command — the functions this module's own doc points every future host at, the browser worker included — took a &Command and matched Command::Model { cmd, .. }, so a batch composed against a document that had since moved landed anyway. The .. discarded the basis with no compile error.

-

The regression guard is the signature, not this test. Both now take a Checked, whose only constructor runs stale_basis, so the version of this test that reproduced the bug — reduce_full(&mut app, &wire) with a stale wire — no longer compiles, and reduce_full itself no longer exists (improvise-s17o: having no caller was the condition that let the defect hide in the first place). What is left to assert is that the constructor refuses, and that refusing leaves the document alone: there is no half-applied batch, because the type is obtained before anything is applied.

-

#a_model_commands_dirty_comes_from_its_effects_kind

-

The Dirty a model command reports is the one its effect declares, not a second table kept alongside the enum (gotcha 35).

-

#a_wire_model_command_marks_the_document_dirty

-

improvise-gv9r. The bug: a client's edit left the authoritative document reporting clean, on every transport.

-

MarkDirty used to be a separate effect that the command layer composed alongside the mutation, and improvise-kmgs derived ModelCommand from the effects one at a time — so the wire got SetCell without its companion. A derivation correct per element and wrong for a set. "Save if dirty" was therefore a trigger that never fired, and a host built on it loses every client edit silently; the browser worker found that out the hard way and had to over-approximate off the command variant instead.

-

The fix is the unrepresentable one: dirtiness is raised by App::apply_effects off the same EffectKind the Dirty and the two basis tiers are read from (gotcha 40), so no path can compose a document change without it. There is nothing left to forget, and MarkDirty is gone rather than left as a second answer.

-

The three levels are asserted separately because they are three different effects: an effect that is only ever reached by one of them would otherwise pass on a sibling's evidence. The negative arm — that a ViewOnly effect leaves the document clean — is a_view_only_batch_leaves_the_document_clean in ui/app.rs, and it has to live there: a view-slice Command never reaches apply_effects at all (apply_command folds it straight into ViewState), so a negative written against this door would pass however unconditional the raise was.

-

#a_wire_view_switch_leaves_the_document_clean

-

The inversion of a_wire_view_switch_marks_the_document_dirty, which asserted the pre-improvise-jytm behaviour. A switch used to move Workbook::active_view, which persists as the Initial View line, so it really did put the document out of step with the file. It no longer moves any document state, so there is nothing to persist and the browser worker's persist trigger (improvise-gv9r) must not fire.

-

The direction matters: the worker asks the document's dirty flag rather than a table of variants, so a switch that dirtied spuriously would cost an OPFS rewrite of the whole document per keystroke (gotcha 41). This is the negative arm — the positive one, that a real model command does dirty, is the loop above it.

-

The consequence to keep in view is a product one, filed as improvise-azhb rather than decided here: a TUI user who switches view and saves now writes the Initial View the document was loaded with.

-
-

#ui/effect.rs

-

#the_document_surface_runs_on_a_non_filesystem_backend

-

improvise-fuj: every effect that touches a document goes through App::storage, so the whole save/load/import/export surface works on a backend that is not a filesystem. This is what a browser worker supplies (improvise-i34); here MemoryFs stands in for it, and the test would fail if any of these effects reached for std::fs.

-

#view_only_effects_leave_shared_state_alone

-

improvise-mae: a declared kind() is only worth having if it matches what the effect actually does. Rather than restate each declaration, this applies the effect and checks the shared state it claims not to touch is genuinely unchanged — a ViewOnly effect that quietly wrote a cell would never reach connected clients.

-

No exemptions: since improvise-8uz moved the cursor out of View, SetSelected/SetRowOffset/SetColOffset touch nothing shared, so every effect in the list must clear the same bar.

-

#structural_effects_declare_their_kind

-

The structural effects, each pinned to the kind it must declare.

-

The bug this guards: AddCategory and RemoveCategory declared CellData, but both go through Workbook, whose on_category_added / on_category_removed rewrite every view's axis map. That is a re-layout, so a client told only "cell data changed" would keep rendering the old grid shape. The two "actually change" tests beside it cannot see this — a category change moves model data and view config, so it passes either list. Only pinning the declaration does.

-

#add_category_past_the_limit_reports_the_failure

-

Bug (improvise-djn): AddCategory discarded the Result from Workbook::add_category, so pressing n in the category panel with the 12-regular-category limit already reached reported the optimistic Added category "X" its command had queued and added nothing. The effect runs last precisely so its failure can overwrite that message (improvise-6t3) — it just never wrote one.

-

#add_formula_adds_target_item_to_category

-

Regression: AddFormula must add the target item to the target category so the grid layout includes cells for it. Without this, a formula like Margin = Profit / Revenue would be registered but invisible in the grid.

-

#abort_chain_short_circuits_apply_effects

-

AbortChain must cause subsequent effects in the same apply_effects batch to be skipped, and the flag must reset so the next dispatch starts clean.

-

#save_without_file_path_aborts_the_chain

-

BUG (improvise-8tk): Save reported failure only through the status bar, so the Quit that wq/ZZ chains after it landed anyway and the whole session was discarded without the user seeing the error. A failed save must abort the rest of the batch.

-

#failed_save_as_aborts_the_chain

-

BUG (improvise-lae, found reviewing it): improvise-8tk fixed one arm of a rule with two. Save aborts the batch when the write fails; SaveAs reported the error to the status bar and let the batch continue, which is the same silent discard 8tk closed — the shape doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names, and the three tests above it are evidence about Save only.

-

It is reachable today: the : line composes with . , so :w /bad/path . q builds exactly this batch, and ExecuteCommand concatenates the two commands' effects into one. improvise-lae's save prompt also commits to SaveAs, so the arm was about to carry more traffic.

-

Note what the abort does and does not buy the prompt: the typed path survives for the user to correct, but the message does not reach the screen, because draw_bottom_bar draws the minibuffer instead of the status line while a prompt is open. That is pre-existing and reported separately; the data-loss half is what this fixes.

-

#successful_save_as_lets_the_chain_continue

-

The positive arm: a SaveAs that succeeds must not abort, or every :w <path> would swallow whatever followed it. Without this, "always abort" passes failed_save_as_aborts_the_chain.

-

#enter_edit_at_cursor_uses_target_mode_not_app_mode

-

EnterEditAtCursor must use its target_mode field, not whatever app.view_state.mode happens to be when applied. The previous implementation branched on app.view_state.mode.is_records(); the parameterized version trusts the caller (keymap or composing command).

-

#enter_edit_at_cursor_reads_post_batch_layout

-

improvise-ehz: the edit buffer must be loaded from layout state that already reflects earlier effects in the same batch. apply_effects only rebuilds after the batch, so a preceding RebuildLayout is what makes the cursor resolve to the post-mutation cell. Removing the row-0 item slides a different cell under the cursor; the buffer must show that cell's value, not the pre-batch one.

-

#set_buffer_does_not_special_case_the_search_name

-

SetBuffer no longer treats "search" as an alias for the search query (improvise-gdl): it is a plain buffer write whatever the name says, and the query is reached through SetSearchQuery. Commands pick between the two in cmd::core::write_buffer.

-

#apply_and_clear_drill_coord_rename_moves_every_measure_in_the_row

-

improvise-c5v: in wide format a records row groups one cell per measure, so renaming a coordinate has to move every one of them. The long-format code moved only the cell under the cursor, which would now split the row in half.

-

#import_headless_csv_short_rows_surface_warning_in_status

-

BUG (improvise-k8i): ImportJsonHeadless parsed CSVs with parse_csv, which sends short-row warnings to stderr — invisible inside the TUI. Short rows must surface in the status message, naming the affected column, never silently succeed.

-

#Note — the drill tests drive apply_effects, not apply

-

Several ApplyAndClearDrill tests call app.apply_effects(vec![...]) rather than effect.apply(&mut app). That is deliberate: the dirty flag is raised off the EffectKind now (improvise-gv9r, gotcha 45), so driving the effect directly would assert a flag nothing in production sets.

-

#every_file_writing_effect_aborts_the_batch_on_failure

-

improvise-z05r, the third instance of one rule: improvise-8tk fixed Save and not SaveAs; PR #27 found SaveAs; ExportCsv was the arm still missing, so a Binding::Sequence or a : line composing an export with anything after it continued past a failed write.

-

The fix is structural rather than a third patch: all three now report through one report_file_write(app, outcome), which writes the status message either way and sets abort_effects on Err. That is not sufficient by itself — a fourth writer could still be written without calling it — so the test names the three, and a fourth has to come here and be named. Naming is the mechanism; the shared function is what makes the naming cheap to honour.

-

Each case drives App::apply_effects with [writer, ChangeMode(Quit)] against a path under a directory that does not exist, and asserts both last_batch_aborted() and that the trailing mode change never landed.

-

Injected fault: drop app.abort_effects = true from report_file_write. Fails by name, along with the four older abort tests — which is the point: they and this one now share one mechanism.

-

Injected fault, narrower: route ExportCsv around the reporter and write its status directly. Only this test fails, naming ExportCsv, with 460 passing — the exact state the ticket describes.

-

#a_batch_that_writes_nothing_leaves_the_document_clean

-

improvise-748f. ViewNavigate { forward: false } emits ApplyAndClearDrill unconditionally, and that effect returns immediately when drill_state is None. It declares Model(CellData) regardless — Effect::kind takes no App, so the classification cannot be state-dependent — so apply_effects raised model_state.dirty on a document nothing had touched. Press view-back, press :q, and the quit is refused; autosave arms thirty seconds later.

-

The older test here, start_drill_and_apply_clear_drill_with_no_edits, pinned the wrong behaviour in an assertion message arguing it was the same over-approximation a failed LoadModel gets. It is not: a succeeding LoadModel leaves a document that really does differ from the file its path names, and that remains declared. This was a wrong answer to no divergence at all.

-

#a_cell_data_effect_no_raw_cell_records_still_dirties_the_document

-

The guard against the cure improvise-748f proposed, which is unsound. The ticket suggested raising dirty off bump_changed_records' per-record fingerprint of the raw cells, on the reading that CellData means cells. Five of the ten CellData effects change the document without writing a raw cell: AddItem, AddItemInGroup and SortData (which reorders — and .improv carries the order), and AddFormula and RemoveFormula, whose results live in Model::formula_cache and not in the store. Observing raw cells would have left every one of them reading clean, which is a lost edit — the opposite direction from the spurious prompt it was fixing.

-

So the observation is document_content — document_config plus the cells in iteration order and the formulas — and only the CellData arm reads it. The LayoutGeometry arm keeps the narrow document_config. A first version pointed both arms at the wide fingerprint, and review benchmarked what that costs at 18,000 cells in release over 200 batches after warm-up: one SetCell batch 8.07 ms → 10.21 ms (+27%, a constant factor on a path that was already O(cells) through record_fingerprints), but one SetNumberFormat batch 78 µs → 2.63 ms, 33× — an asymptotic change from O(config) to O(cells + config) on the held-key path that [/], axis cycle, hide/show, group toggle, transpose and prune toggle all take. ~380/sec where they had been ~12,800/sec. The split costs no correctness because all five blind effects are CellData; that every LayoutGeometry effect really is visible to the narrow fingerprint is asserted separately, and not left as a reading.

-

WholeWorkbook stays declared, and that is the remaining over-report: a LoadModel whose incoming content happens to be identical still marks the document dirty, and a failed one does too. Both cost one redundant write.

-

The SortData case needs two cells inserted out of sorted order, or it reorders nothing and its case asserts the opposite of what it names. The AddFormula case removes the fixture's formula first, or adding it is a no-op.

-

Injected fault: narrow document_content back to the configuration it hashed before. Fails naming SortData, with five other dirty-flag tests.

-

#every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint

-

The guard that makes the two-fingerprint split safe rather than a trap. Splitting CellData (wide) from LayoutGeometry (narrow) is only sound while every effect wearing LayoutGeometry writes something document_config hashes; an effect it cannot see would leave the document reading clean forever, which is the same lost-edit failure the wide fingerprint exists to prevent, arriving by the other door.

-

The thirteen are enumerated by brace-matching the impl Effect blocks, not by grepping — the naive grep reports fourteen, picking up HelpPageSet, which is ViewOnly and is simply the last pub struct before #[cfg(test)] (gotcha 38 names this exact artefact). Each case asserts its effect really declares Model(LayoutGeometry) before asserting anything else, so a reclassified effect fails here rather than silently leaving the list unrepresentative.

-

Four cases carry a prepare step run directly against the workbook and not through an effect, because the interesting state is the precondition. What makes such a case honest is the no-op-fails property: without its precondition the effect writes nothing, an unwritten document leaves the fingerprint unmoved, and the case therefore fails rather than passing vacuously.

-

Measured, by deleting each prepare in turn, two of the four are load-bearing in exactly that way and two are not:

-
    -
  • DeleteView needs a second view; without it, dirty reads false and the case fails.
  • -
  • ShowItem needs a hidden item; without it, dirty reads false and the case fails.
  • -
  • ToggleGroup is not load-bearing here. ToggleGroup::apply calls toggle_group_collapse unconditionally — its membership check gates only reset_cursor — so collapsed_groups moves whether or not the group is real, and the case passes with the prepare deleted. The step documents the realistic input; it is the cursor arm, tested elsewhere, that needs a real group.
  • -
  • TransposeAxes is not load-bearing either, and its step is literally a no-op: View::on_category_added already assigns the first regular category Row and the second Column, so the fixture arrives with Type/Month differing. The step states the precondition rather than establishing it, and the case passes without it.
  • -
-

dirty is cleared after prepare and before the effect. That clear is defensive hygiene for a future prepare that goes through apply_effects, and it is not what makes any case honest today: dirty is raised only inside App::apply_effects, and no prepare in this test runs an effect, so no preparation can set it. Demonstrated by removing the clear and blinding document_config to 0 together: all thirteen cases still read dirty == false. An earlier version of this entry claimed the opposite — that without the clear "all four cases pass against any fingerprint" — and measurement contradicts it.

-

Injected fault: document_config returns 0. Fails, with five other dirty-flag tests across three files. Injected fault, narrower and the one that proves the test is pointed at the right thing: drop view.number_format from document_config. Fails naming SetNumberFormat, with only one other test failing anywhere.

-
-

#ui/app.rs

-

#model_state_and_view_state_are_constructible

-

improvise-3vr: ModelState and ViewState are the named slices of App state introduced by the vb4 refactor. Step 1 only requires that the types exist and are constructible; subsequent steps move fields in.

-

#Note — the ViewState: Send static assertion

-

improvise-h0b: ViewState is the portable slice — the one thing serialized into the Snapshot a newly-connected client receives (improvise-q08). Send is the cheapest compile-time proxy for "portable": a field that cannot cross a thread boundary certainly cannot cross a serialization one. Before h0b this failed to compile, because drill_state: Option<DrillState> holds an Rc<Vec<RecordRow>>. Session-local state lives on App instead. It is a const _: fn() in the test module, not a named test, so it has no heading of its own.

-

#app_view_state_owns_ui_session_fields

-

improvise-ew0: ViewState owns the UI session slice — mode, status, search, panels, navigation, drill, yanked, buffers. App accesses them through view_state.

-

#view_state_survives_a_json_round_trip

-

improvise-a2x: ViewState is what a newly-connected client receives as a Snapshot (improvise-q08), so it has to survive a round trip through the wire format. Populated with non-default values in every field family — a mode carrying a MinibufferConfig, a navigation frame, a yanked cell, a keyed buffer, an armed prefix — because a roundtrip of ViewState::default() would pass while proving nothing.

-

#the_documents_default_view_outlives_this_clients_switches

-

The replacement for the_two_active_view_names_never_diverge, and it is a replacement rather than a deletion on purpose: improvise-jytm makes the two copies diverge deliberately, and a deliberate split with no guard is how one silently regresses into the other.

-

What it pins, all four of which have a silent failure mode. A switch moves the client's copy and not the document's default. App::view() — what rebuild_layout and cmd_context read — follows the client, so the split is observable in the layout and not merely in a string. A switch to a view the document does not have moves nothing, because the slice keys ViewState::cursors and grid_in indexes the workbook by it. And install_workbook still seats this client on the incoming document's default and clears the cursor map, since the outgoing document's view names mean nothing in the new one (gotcha 24).

-

Fault injected 2026-09-20: App::switch_to_view calling workbook.switch_view(name) again fails the second assertion.

-

#moving_the_cursor_does_not_touch_the_shared_workbook

-

improvise-8uz: moving the cursor must leave the shared document untouched, so that one client scrolling cannot move another's viewport. Before the move these three effects wrote into model_state.workbook, and this assertion would have failed.

-

#app_owns_the_session_local_fields_not_view_state

-

improvise-h0b: the two session-local fields moved off ViewState and onto App. Each fails both membership tests: ImportWizard is an improvise-io type (not portable) and a half-open dialog (not snapshot-safe); DrillState holds an Rc (not portable — see the Send assertion) and stages uncommitted edits (not snapshot-safe).

-

#staged_drill_edits_are_unreachable_from_view_state

-

improvise-h0b: a fresh client must not inherit another session's staged, uncommitted drill edits. Those edits are unreachable from ViewState, so a Snapshot built from it cannot carry them.

-

#switching_views_restores_each_views_own_cursor

-

improvise-8uz: the cursor is per view. Written as a characterization test against the old View-owned fields and kept verbatim across the move to ViewState::cursors, because a refactor about where state lives must not change what the app does.

-

#the_improv_format_does_not_persist_the_cursor

-

improvise-8uz: the ticket asserted these fields were "PERSISTED in .improv". They were not — the grammar has no cursor production and the writer emitted none. That is what made the move free: there was no restore-where-you-left-off behaviour to lose, and no format change to make. The assertion still guards the format going forward.

-

#entering_records_mode_sorts_existing_data

-

Pressing R to enter records mode sorts existing data by CellKey, so display order is deterministic regardless of insertion order.

-

Note: this test's doc comment carried a stray first line from a neighbouring test ("Regression: pressing o in an empty records view should create the") that was never finished. It was dropped in the comment-cap wave; the sentence above is what the test actually asserts.

-

#the_new_category_column_creates_a_category_and_its_column

-

improvise-2zq step 2: the +cat column creates a category and its column, without leaving the grid.

-

Driven through real keys. The assertion that earns its place is the last one: the new column is usable immediately, which is the whole point — records_columns filters on kind.is_regular() and never requires items, so a category with no items is a column the moment it exists.

-

#enter_on_plus_cat_in_the_last_row_does_not_reopen_category_creation

-

improvise-2zq, found by Copilot on PR #27: creating a category from +cat shifted the grid under the advance that followed it.

-

add_category inserts the new coordinate column immediately before +cat, so every column index from +cat rightwards moves by one. CommitAndAdvance then computed its advance from ctx, whose layout is the pre-commit one (gotcha 23: apply_effects rebuilds only after the whole batch). On the last row Enter wraps to the next column — and the index it wrapped to was +cat again after the rebuild.

-

So the failure is not "lands one column off". It reopens category creation: a user naming two categories in a row never reaches the column they just made, and gets an unbounded +cat loop instead. The last row is the arm that wraps, so it is the arm tested.

-

#tab_on_plus_cat_does_not_reopen_category_creation

-

The same stale index reached Tab, which advances right rather than wrapping. Both directions read the pre-commit layout, so both are covered — one arm of a two-arm rule is the shape doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names.

-

#the_new_category_column_creates_the_column_but_writes_no_coordinate

-

Committing the +cat name creates the column and does not write the coordinate on the row the cursor was on.

-

The deliberate answer to the ticket's third question, and the reason is gotcha 1: setting a coordinate moves every measure cell in the row. That is a data migration across N cells, and attaching it to the same Enter that made a schema change — in an app with no undo — is a bigger single keystroke than the measure + asks for. The follow-up edit is one column away.

-

#the_new_category_column_refuses_past_the_limit_where_the_user_can_see_it

-

MAX_CATEGORIES is a reachable refusal on this path, and the refusal has to be visible.

-

draw_bottom_bar renders the minibuffer instead of the status line, so a message written while an editor is open is never drawn. The commit path therefore drops out of editing before running the add, and AddCategory aborts the batch on refusal so the trailing EnterEditAtCursor cannot re-open the editor over the message.

-

The limit itself is checked in exactly one place — Model::add_category — and nothing here restates it; the test fills the document to the limit by asking for categories until it refuses.

-

#naming_a_category_in_records_mode_does_not_eject_the_user

-

improvise-2zq step 1: naming a category in records mode used to eject the user from the grid they were standing in.

-

Records mode is _Index on Row and _Dim on Column, with every regular category on Axis::None. View::on_category_added seated a new regular category on the first axis no regular category held — Row — and bumped the virtual out of the way to get there. So N, a name, Enter moved _Index off Row, records detection stopped matching, and the grid silently became a pivot.

-

Driven through the real keys rather than through on_category_added, because that is how the bug was found and it is what makes this evidence rather than a restatement of the fix.

-

#i_and_o_diverge_on_an_empty_records_grid

-

improvise-6sq: i and o want opposite behaviour on an empty records grid, which is why edit-or-drill and enter-edit-at-cursor cannot be merged into one command. Entering records mode puts every regular category on Axis::None, so EditOrDrill's aggregation check is true unless the cursor is on a synthetic record — and on an empty grid there is none, so i reports "cannot drill". o must still open an edit there (see add_record_row_in_empty_records_view_creates_first_row), so routing it through the same drill check would break it.

-

improvise-ct5 changed the message and deliberately not the divergence. The outcome — i opens no editor where there is nothing to edit — is right, and o one key away is the affordance for adding a row. What was wrong is that the message said "cannot drill", a pivot-mode concept in a mode whose model is "a table I type into". Making i behave like o here (the ticket's option (b)) would collapse exactly the divergence improvise-6sq exists to record, so the assertion still pins that i does not enter editing; only the wording assertion moved, and it now also names what must not appear.

-

#the_empty_grid_message_is_not_the_records_one_in_pivot_mode

-

The negative arm of improvise-ct5, and the reason the wording is keyed off the mode the keymap supplies rather than reworded once.

-

Without this, "say the records sentence everywhere" passes i_and_o_diverge_on_an_empty_records_grid — and it would promise o, which adds no row in pivot mode, trading one misleading message for another.

-

The fixture is an empty pivot grid that still counts as aggregated: Region on Row with no items makes cell_key() None, and Extra cycled to Axis::None is the regular category that makes EditOrDrill take the drill branch.

-

#blank_model_bootstraps_its_first_measure_from_the_new_measure_column

-

A blank model has no categories and no measures, so wide format shows exactly the two creation columns. Typing a name into the new-measure one creates the measure, which gives it a real column to type values into — records mode stays self-sufficient for onboarding (improvise-c5v).

-

improvise-2zq widened that: a blank grid now offers +cat beside +, so both halves of a schema can be bootstrapped without leaving the grid. This test still pins the measure half; the category half is the_new_category_column_creates_a_category_and_its_column.

-

Inside it, the cursor lands at column 0, which in a blank grid is now +cat rather than + (improvise-2zq) — a record needs coordinates before it needs measures, so that order is deliberate. o leaves the test editing column 0, so the Esc before moving to the new-measure column is load-bearing.

-

#ctrl_w_prompts_for_a_path_and_a_failed_write_reports_it_where_it_shows

-

improvise-lae end to end: Ctrl-W, a path, Enter.

-

The failing case is the interesting one, and it pins an ordering (found by Copilot on PR #27). SaveAs aborts the batch on a failed write, so whatever CommitSave emits after it is skipped. A first version emitted [SaveAs, ChangeMode(Normal)], which left the app in SavePrompt — and draw_bottom_bar renders the minibuffer instead of the status line, so the "Save error:" the effect had just set was never drawn. The save appeared to do nothing whatsoever.

-

Leading with the mode change puts the error in Normal, where the bottom row is the status bar. This test therefore asserts the mode left the prompt, which is the opposite of what it asserted before — deliberately, and it is the only thing keeping the two effects in that order.

-

#every_minibuffer_mode_entered_from_records_returns_to_records_normal

-

Copilot round 2 reported that Ctrl-W from a records grid returns to Normal, losing the records keymap while the layout is still a records layout. It is right, and it is a class: every minibuffer mode reachable from RecordsNormal exited to Normal, because the exit was a constant baked into TextEntry::new(..., "normal") and into each commit command.

-

The class predates SavePrompt: : and Ctrl-E do exactly the same thing and always have. That is why the fix was not "make SavePrompt remember its origin" — that would leave two of the three arms broken, which is the shape doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only) names.

-

The binding table is not involved and was checked rather than reasoned about: an IMPROVISE_BINDING_DUMP diff shows RecordsNormal gains Ctrl-W and loses nothing, so with_parent inheritance (gotcha 13) is intact. What was lost is the runtime mode.

-

improvise-sihr fixes it by observation rather than memory, which is what took the exit out of the keymap entirely: App::apply_effects calls settle_normal_mode after rebuild_layout, so the mode a batch ends in matches the grid it ends on and nothing carries an entry mode. The ticket proposed the opposite — carrying the entry mode on MinibufferConfig — and that was declined for a concrete reason beyond doctrine: carried generically it would also return CategoryAdd to CategoryPanel, a behaviour change nobody asked for, while carried for only three modes it is the one-arm shape again.

-

This test still drives both exits — Esc and Enter — where the original drove only Esc, because they reach the mode through different effects (ChangeMode and ReturnToNormalUnlessMoved) even though one settle now covers both.

-

The first version of the fix resolved per-effect and was wrong in a new direction, which the adversarial review of PR #91 caught. Set at the moment the mode is written, the resolution reads the batch's opening layout (gotcha 23) — so a batch that crosses settled against the grid it started on. See a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal.

-

Injected fault: settle_normal_mode()'s call site removed. Exactly three tests fail across the whole workspace — this one, the :-line sibling and the crossing test — which is the per-edit coverage question answered by there being one edit.

-

#a_colon_line_run_from_records_settles_back_into_records_normal

-

The sibling above cannot see ReturnToNormalUnlessMoved, and that was found by injection rather than by reading: forcing only that effect back to AppMode::Normal left the sibling green. An empty : line — which is what pressing Enter straight after : gives — takes ExecuteCommand's raw.is_empty() branch and settles through ChangeMode, so the effect that settles every line that actually runs was untested.

-

So this test types a line. page-next rather than yank, because the script parser requires coordinates for yank and an unparseable line takes the error branch — which settles through ChangeMode too, and would have made this test a second copy of its sibling while looking like a new case. The setup assertion that parse_line_with accepts the line is there for exactly that: a future rename of the command would otherwise turn this test vacuous silently.

-

Injected fault (against the per-effect version of the fix): ReturnToNormalUnlessMoved forced to AppMode::Normal. This test failed, left: Normal right: RecordsNormal, while the sibling stayed green — which is the point, and is how this test came to exist. Against the shipped end-of-batch settle the discriminating injection is removing settle_normal_mode()'s call site, which fails this test with the same values.

-

#a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal

-

New wrong state introduced by PR #91's first attempt, found by the adversarial review and verified by reverting only ui/app.rs and ui/effect.rs to base and re-running the identical probe:

-
:toggle-records-mode . enter-mode normalbase#91 round 1
from a pivot gridNormal (the sihr bug)Normal still
from a records gridNormal (correct)RecordsNormal
-

The cause is gotcha 23: resolving at the moment the mode is written reads the batch's opening layout, so a batch that crosses settles against the grid it started on. :toggle-records-mode is the case the round-1 prose offered as proof that the staleness was a property — and it is not, because ToggleRecordsMode emits change_mode(RecordsNormal) explicitly on entry and ViewBack restores the pushed mode on exit, so the resolution's answer is overwritten in both directions and the case exercises nothing. That is a careful comment standing in for a test, in the PR that recorded doctrine(an_injection_that_does_not_fail_names_an_untested_path).

-

The fix moves the resolution to one place, settle_normal_mode after rebuild_layout, where there is no crossing case left to get wrong.

-

This test drives both directions deliberately. One direction alone is satisfied by a settle that only ever answers one way, and the pivot→records direction was also wrong in round 1 for the mirror-image reason.

-

#a_wizard_abandoned_over_a_records_grid_returns_to_records_normal

-

CloseWizard writes view_state.mode directly rather than through an effect that could resolve it, so under round 1's per-effect design it was a third writer needing its own edit — and reverting that edit alone left the whole workspace green, which the review filed. The end-of-batch settle removes the per-edit question entirely (there is one edit), and this test covers the path the review named: the wizard abandoned over a records grid.

-

It drives CloseWizard as an effect rather than typing :import, because the wizard needs a real CSV and the contract under test is one batch's settling, not the import pipeline.

-

#a_failed_colon_write_lands_in_normal_where_its_error_is_drawn

-

A failing : command must not strand the user in CommandMode.

-

Found by Copilot on PR #27, and it is the sharper form of gotcha 21. The abort is supposed to stop the chain — :w /bad . q must not quit. What it must not also stop is the bookkeeping: the mode restoration that ExecuteCommand appends. Aborting before that ran left the app in CommandMode with the stale command line still drawn, and draw_bottom_bar renders the minibuffer instead of the status line — so the "Save error:" set one effect earlier was invisible. A failed save looked exactly like a key that did nothing.

-

The answer is ordering, not classification: a batch puts its tidy-up where an abort cannot reach it, which is in front. No effect needs a new "runs anyway" flag, and the rule generalises to any command composing bookkeeping with something that can fail.

-

Both arms are here because they fail in different places: no path at all is Save's guard (aborting since improvise-8tk, so this arm was broken before that branch), and a bad path is SaveAs's write (aborting since f0f400d).

-

#a_failed_colon_write_still_refuses_to_quit_after_it

-

The other half of the same rule: the abort must still stop the chain. :w <bad> . q is improvise-8tk's data loss spelled on one : line, and moving the mode restoration to the front must not have bought the fix above at the price of that one.

-

#a_sequence_whose_first_step_aborts_does_not_run_the_second

-

Gotcha 21 claims a failed Save aborts across Binding::Sequence steps as well as within one command's effect list. Nothing tested it: no shipped Sequence has a first step that can abort, and ZZ is a prefix resolving to the single wq command, so prefix_z_then_z_reaches_save_and_quit exercises the within-a-command case only. The claim was structurally true and unguarded.

-

It is true because Keymap::dispatch flattens a Sequence into ONE effect vec and App::apply_effects runs that as one batch, breaking on abort_effects. It is load-bearing, because it is the reason a [w, force-quit] Sequence would be safe — the argument the grooming note unblocked on.

-

And it is exactly what improvise-0hb would remove. Command-level transactionality means applying effects between steps, and apply_effects resets abort_effects at entry and again on abort, so step 2 would be dispatched into a fresh batch with the flag cleared — the Quit after a failed save would land, silently reopening the improvise-8tk P1 data loss. This is the guard that would catch it.

-

#unmatched_key_after_prefix_is_swallowed

-

A prefix is consumed whether or not the next key matches, so a stray key after g does nothing rather than being treated as a fresh normal-mode binding.

-

#renaming_a_coordinate_moves_every_measure_in_the_row

-

improvise-c5v: a records row groups one cell per measure, so editing a category column has to move every one of them. The long-format code moved only the cell under the cursor, which in wide format would tear the row apart — half its measures under the old coordinate, half under the new one.

-

#leaving_records_mode_cleans_empty_key_cells

-

improvise-3zq (bug #2): AddRecordRow creates a cell with an empty CellKey when no Page-axis categories supply coords — that cell serialises as = 0 in .improv and re-appears on every records toggle. Leaving records mode must clean up any such meaningless records (the inverse of the SortData that runs on entry).

-

#enter_at_bottom_right_of_records_view_exits_editing

-

improvise-3zq (bug #1): Enter on the bottom-right cell of records view should commit and leave edit mode. Previously CommitAndAdvance pushed an EnterEditAtCursor effect unconditionally, so the cursor stayed put and editing re-opened on the same cell.

-

#Helper — records_model_with_two_rows

-

Not a test — the fixture for every Tab/Enter boundary test in this file.

-

A records-mode app with two data rows, for testing Tab/Enter behaviour at boundaries. In wide format a row is one coordinate tuple, so the two rows are two Region items, each carrying both measures (improvise-c5v).

-

#tab_on_bottom_right_of_records_inserts_below

-

improvise-hmu: Tab on the bottom-right cell of records view inserts a new record below and moves to the first cell of the new row in edit mode.

-

#Note — the wizard_key_tests module

-

Behavioural coverage for the import wizard's key handling (improvise-2wm). The wizard is driven entirely through handle_key, so these tests pin the user-visible contract independently of whether the step/key decisions live in an effect or in a command.

-

#name_model_typing_edits_the_name_and_enter_confirms_the_import

-

Enter at NameModel builds and parks on the Done confirmation; a second Enter there installs the workbook and closes the wizard (improvise-907, from improvise-c6j).

-

#review_proposals_blocks_when_no_category_is_accepted

-

BUG (improvise-907, from improvise-gfq): an import whose fields all classify as Measure used to walk the user through DefineFormulas and NameModel before build_model refused. The refusal now happens at ReviewProposals, where c can still change a field's kind.

-

#left_walks_back_through_the_steps_taken

-

BUG (improvise-907, from improvise-o1c): reaching a later step and wanting to change a field kind meant Esc, which discarded every decision. Left walks back through the path actually taken (gotcha 22).

-

#done_esc_returns_to_name_model_and_drops_the_built_model

-

Esc at Done goes back to NameModel rather than discarding the import, and the workbook built for confirmation is dropped so a later accept cannot install stale field decisions.

-

#Note — the basis_tests module fixture

-

The two lower tiers of a batch's basis (ADR 001, improvise-xwi6). Fixture note: two regular categories, two measures and two records, deliberately. A one-category fixture cannot tell TransposeAxes from CycleAxis, a one-record fixture cannot tell "the record it touched" from "every record", and a one-measure fixture cannot tell a record from a cell — the three distinctions these tests exist to draw.

-

#a_records_two_measures_share_one_record_key

-

The record is the coordinate tuple with _Measure gone, and both measures of one coordinate name the same record, which is the whole point of the unit.

-

#a_layout_change_moves_the_layout_version_and_no_record

-

Tier 2 moves, tier 3 does not. A shape change invalidates every in-flight CellData batch through layout_version alone; it must not also masquerade as a write to any record.

-

Fixture repaired under improvise-75ld: it asked for SetAxis { category: "Month", axis: Axis::Column } against a fixture where on_category_added had already seated Month on Column — so the "layout change" was a no-op, and the test passed only because the tier was bumped off the declaration. It now asserts the axis before and after, and asks for Axis::Row. This is the failure that turned "reclassify three effects" into "stop reading the tier off the classification". Injected fault: the same as both_shape_counters_move_for_every_reshaping_command — fails naming SetAxis.

-

#a_loaded_document_is_dirty_because_its_path_still_names_the_old_one

-

A loaded document is dirty, and that is a behaviour change with a reason (improvise-gv9r, raised by the independent review of PR #26).

-

LoadModel is Model(WholeWorkbook), so apply_effects raises the flag — where before that branch :e other.improv left the document reporting clean. Clean was the wrong answer: LoadModel does not move model_state.file_path, so after it the in-memory document is a different one from the file the path names, and the bytes on disk and the bytes in memory really have parted company. That is all dirty claims.

-

It is worth a test rather than a comment because of what it gates: autosave_if_needed and the :q refusal both read this flag, so a user who loads and quits is now asked rather than silently dropping a document whose path is stale. :q! still discards. The path-staleness itself is pre-existing and is reported separately; this pins only that the flag follows from it.

-

#a_view_only_batch_leaves_the_document_clean

-

The negative arm of improvise-gv9r's raise, and it must be aimed at apply_effects rather than at a Command: a view-slice command is folded straight into ViewState and never reaches this loop, so a negative written against the session door would pass however unconditional the raise was. It is a ViewOnly effect run through the loop that discriminates.

-

What it pins: a client's cursor, status line or panel does not put the document out of step with the bytes on disk, so it must not arm an unsaved-changes prompt or an autosave.

-

#a_cell_edit_moves_only_the_record_it_touched

-

Tier 3 moves for the record written and for no other, and tier 2 stays put: a cell edit does not invalidate batches that merely share the document.

-

#a_coordinate_rename_is_one_write_to_each_record_not_one_per_measure

-

The unit is the record, not the cell. Renaming a coordinate in wide records mode moves every measure cell in the row (gotcha 1), and that is ONE write to the record it left and ONE to the record it arrived at — not one per measure.

-

#an_aborted_batch_bumps_nothing_it_did_not_run

-

Gotcha 21: a batch that aborts halfway must bump nothing after the abort — in either tier. apply_effects accumulates the Dirty inside the loop for exactly this reason, and the versions are read off the same place.

-

#a_write_that_changes_nothing_moves_no_version

-

A write that lands the value already there invalidates nobody's basis, so it moves no version. This is a deliberate choice of semantics — observed change, not "a CellData effect ran" — and the safe direction, because the alternative rejects batches for nothing.

-

#the_version_tiers_are_not_in_the_view_state

-

The counters are shared document state on App, never on the portable session slice (gotcha 30): a client inheriting another's idea of how far the document has moved is exactly the stale basis this machinery exists to catch.

-

#a_page_flip_under_prune_empty_invalidates_the_cursor

-

improvise-7kvw, the case improvise-9mo9 decided the other nine and deliberately left open. SetPageSelection changes which records are visible and not which categories frame them, so a grid position normally keeps naming the same cell. Under View::prune_empty that is false: which rows survive pruning is a function of the page selection, so a page flip renumbers the grid while neither reset_cursor nor cursor_epoch moved — gotcha 24's failure in both directions at once, the host's own cursor stale and no client told.

-

9mo9 did not patch it because [/] paging is constant navigation and sending the cursor home on every flip is worse than the hole when prune_empty is off, which is the default. So the reset is conditional on prune_empty, and on the selection actually moving.

-

The fixture has three regions and two quarters whose non-empty rows differ, so position 0 names North on Q1 and South on Q2. The test asserts the row labels really differ before asserting the epoch moved; the negative arm asserts they really do not differ with pruning off, so neither case can pass vacuously.

-

Injected fault: drop the reset. The prune arm fails. Injected fault: make the reset unconditional. The no-prune arm fails.

-

#a_page_flip_without_prune_empty_leaves_the_cursor_alone

-

The negative arm of the above, and it is load-bearing for the same reason gotcha 24 gives for SetNumberFormat: without it, "reset on every page flip" passes the positive test and costs the user their position on every [/].

-

#selecting_the_page_already_selected_invalidates_no_cursor

-

The third arm, and the one the user actually hits. SetPageSelection resets the cursor only when the selection moved, and that guard shipped untested: deleting moved && left the whole workspace green at 1416, which review caught.

-

It is not an edge case. PageNext/PagePrev are odometer-style and emit a SetPageSelection for every page category on every keypress, so with more than one page category the non-carrying categories are always no-ops, and a single one-item category re-selects the same item every time. Unguarded, each such keystroke bumps cursor_epoch, sends the host's cursor to the origin and broadcasts Command::ResetCursor to every client on that view — improvise-75ld's "a no-op that refused everybody else", one tier down and aimed at the cursor instead of the basis.

-

The test asserts both preconditions before applying the effect — that the fixture really is on Q1, and that the grid really does prune — and that the row labels did not move before asserting the epoch stood still. So it cannot pass by re-selecting a page that was never selected, or by testing a grid that does not prune.

-

Both before placements are load-bearing, and both were wrong once. The page check used to run after the effect, which sets the page to Q1, so it could not see a fixture that started elsewhere: with the fixture moved to Q2 the test did fail, but at the row-labels assertion, under a message about renumbering. The sentence naming the property was not the assertion guarding it. The prune_empty check did not exist at all, and with pruning off the reset never fires whatever moved says — so paged_model(false) plus the deleted guard left the test green, proving nothing, with nothing saying so.

-

Both preconditions read App::view(), the view the effect writes through app.view_mut(), and not Workbook::active_view(), which is the document's default and is deliberately allowed to differ from it (gotcha 29). They agree in this single-client fixture, so the distinction is latent rather than a live bug — but an assertion about what an effect did must read the view that effect wrote.

-

Injected fault: delete moved &&. Fails by name, alone. Injected fault: paged_model(false) and delete moved &&. Fails on the prune_empty assertion; before that assertion existed, this combination passed. Injected fault: paged_model's fixture starts on Q2. Fails on the page-selection assertion, which now names it; before the move it failed on the row labels instead.

-

#a_foreign_layout_is_not_shown_the_hosts_staged_drill_text

-

improvise-pubt, filed with an argument for unreachability that the test disproves. project::cell_display fills edit_text from App::display_value_in(layout, key), and since improvise-jytm that layout may be another client's. drill_state.pending_edits are keyed by (row index, RecordCol) against the host's drill layout (gotcha 1), and a row index in one layout names nothing in another.

-

The ticket read the arm as unreachable because "a foreign layout built by GridLayout::new carries no frozen records, so it should not produce a synthetic key". That is wrong: records mode auto-detects from the axes (gotcha 1), so an ordinary saved view with _Index on Row and _Dim on Column gives GridLayout::new a records layout whose cell_key returns exactly the synthetic (_Index, _Dim) key the drill arm matches on. The test fails on the original code, naming the host's staged text read out of a layout it was never staged against.

-

The fix is App::staged_drill_edit, which consults drill_state only when std::ptr::eq(layout, &self.layout). A pointer comparison rather than a name comparison because grid_in reuses &app.layout by reference where the view names agree and derives a fresh one otherwise, so identity is the question being asked. Whether the host's pending drill text should reach any other client at all is improvise-tom1 and is untouched here.

-

The positive arm — the host's own layout still shows STAGED — is in the same test, or "never consult the drill" passes, which would blank the TUI's own drill editor. The drill fixture needs a real RecordRow, not an empty Vec: the host's layout iterates the frozen snapshot, so an empty one gives a zero-row grid and the positive arm cannot be built.

-

#a_default_that_names_nothing_says_which_invariant_broke

-

improvise-h074. App::resolve_view falls back to workbook.active_view for a name the document has not got and then indexes workbook.views[default]. Several of these call sites previously went through Workbook::active_view(), which carries .expect("active_view always names an existing view"); IndexMap's Index impl panics with no message at all, so a workbook whose default names nothing — reachable only by constructing the struct directly, since create_view/delete_view maintain it — failed with a bare index panic where it used to name the broken invariant.

-

Injected fault: restore &workbook.views[default]. The test fails on the expected substring.

-
-

#ui/project.rs

-

#a_window_reaching_the_last_row_carries_the_trailing_group_header

-

A collapsed group at the bottom of the grid has no items to hang off, and is still the only row that says those rows exist. A window that reaches the last row therefore carries it.

-

It must be seen; it is not yet actionable in a browser. web/app.js registers no listener on a group <th>, and toggle-group-under-cursor is still in the_command_names_that_still_need_the_host_are_these, so z resolves Upstream into Host::handle's empty vec — a black hole, not a slow path (doctrine(an_upstream_key_is_a_black_hole)). The TUI can expand it today; the browser can only show it.

-

#Helper — app_with_a_trailing_group

-

Not a test — the fixture for that test and for its stops-short arm.

-

Region on Row with East in a group that comes last, so the group header is the final entry on the axis and nothing follows it. That is the shape axis_frame's trailing rule is about.

-

#a_window_that_stops_short_does_not_carry_what_is_below_it

-

The other arm, and the one that would make a browser scrolled to the middle of a document draw the bottom of it: a window that stops short of the last row carries nothing below its last item.

-

#a_window_opening_inside_a_group_names_only_the_nearest

-

A window opening inside a group still names it — otherwise a browser could not say which group a column or row belongs to — while body_from declines to paint a header that sits above the window.

-

And only the nearest one comes. The fixture puts two headers above the window (Hidden is collapsed, so it emits a header and no items, and Late's follows it immediately) because that is the only way to tell "the nearest header comes" from "every header above comes". With one group above, deleting the compaction leaves this green — it is a cost property, O(the groups a deep scroll crossed), and correctness alone cannot see it: group_of still finds the nearest and body_from still starts at the first item either way.

-

#Helper — app_with_consecutive_headers

-

Not a test — the fixture for that test and for consecutive_headers_between_two_rows_all_come.

-

Two consecutive group headers between two rows: Hidden is collapsed, so it emits a header and no items, and Late's header follows it immediately.

-

#consecutive_headers_between_two_rows_all_come

-

Headers between two in-window rows all come, not just the nearest.

-

Only the nearest wins group_of, so a compaction that kept one would still answer every question about membership correctly — and would silently lose the Hidden header, which is the only thing on screen that says those rows exist at all, and the only thing that will be clickable once the browser can toggle a group. It cannot today: z is Upstream and answered with nothing.

-

Above the window the rule is the opposite, and that arm is a_window_opening_inside_a_group_names_it_without_painting_it.

-

#folding_what_the_server_emits_reproduces_what_the_server_has

-

The contract of the whole module: a client that folds what the server sends ends up holding what the server has. Not the fold axiom — the yardstick (server_truth) is built independently of the projections.

-

#Helper — server_truth

-

Not a test — the yardstick that test and its siblings measure a fold against.

-

What the server believes the client should be holding, built straight from the authoritative state — the yardstick every fold is measured against, and built independently of the projections so the fold tests are not vacuous (doctrine(fold_tests_must_test_order_not_the_axiom)).

-

#removing_a_category_empties_the_clients_formula_panel

-

Removing a category removes every formula that targets it — Model::remove_category ends with formulas.retain(|f| f.target_category != name). RemoveCategory is LayoutGeometry, and Dirty::of(LayoutGeometry) reported formulas: false, so the formula panel was never told: a client's FormulaCache kept showing a formula the document no longer has, indefinitely, with nothing to bring it back short of a reconnect. (Gotcha 35.)

-

#a_late_client_converges_with_one_that_followed_from_the_start

-

Bootstrap is a compaction, not a replay: a client that connects late and folds only the seed lands exactly where one that followed the whole stream is.

-

#the_projected_extent_is_the_whole_grid_not_the_window

-

The extent is the whole grid, not the window (improvise-bpq2).

-

A client resolving its own cursor keys clamps against this, so a producer that read it off the projected ranges would stop every browser cursor at the bottom of the screen — and would look right in the TUI, whose cache covers the whole grid at offsets zero (gotcha 44). The fixture is deliberately not square (3 rows, 2 columns) and the window is smaller in both directions, so it separates "the whole grid" from "the window" and from "rows and cols swapped".

-

#a_reshaped_grid_folds_to_the_desired_state

-

Reshaping is the case the ordering exists for. Growing is the half that bites: cells at positions the client does not have yet are dropped, so the labels that create those positions must go first. Shrinking is here for the other direction — cells that no longer fit must not be sent into a grid that has already been cut down.

-

#a_renamed_column_whose_total_did_not_change_still_gets_its_total_back

-

improvise-hkt6, found by Copilot on PR 20. The diff must carry the mirror to desired, and it stopped doing so for one shape: a column renamed while its total stayed the same number.

-

apply blanks the total under a relabelled column (gotcha 33(b)), so the client's total went blank; but the diff compared mirror.totals against desired.totals, found them equal, and sent no Totals. ClientSession::project_diff then assigns self.grid = desired, so the server believed the number was still on screen — a cache wrong forever, with nothing in the stream to correct it, which is precisely the direction gotcha 35 keeps Dirty::of sloppy to avoid.

-

The assertion is the one a_reshaped_grid_folds_to_the_desired_state makes — fold the updates onto the mirror and require the result to be desired — because "a Totals update was emitted" would pass for a producer that emitted the wrong totals.

-

The fixture renames the middle column and keeps every total equal, so it separates "re-sends what the relabel blanked" from "re-sends whenever the labels move at all".

-

#renaming_an_axis_category_resends_every_cell

-

The producer half of the category-rename rule, and the reason the apply half is not cosmetic: the two compose into "wrong forever" without it.

-

grid_updates diffs the cells against the framed mirror — the mirror with the frame updates already folded in. So an apply that kept the cells through a cats change made the producer agree they matched and send nothing, ClientSession::project_diff then assigned self.grid = desired, and the client held cells answering the old coordinates with no update in the stream that would ever correct them.

-

An axis swap is the case that bites: two categories can legally share an item name, so Region = [North] becoming Department = [North] leaves the labels identical and the CellIds valid and pointing at the other record.

-

#a_session_that_switches_views_is_projected_the_view_it_switched_to

-

The original bug: ClientSession::apply folded straight into the session's mirror, so a Command::SwitchView moved the mirrored active_view while the workbook's stayed put (gotcha 29). viewport() then read the cursor under the new view's key and the next project() built the grid from the other view.

-

improvise-jytm answers it the other way round — the mirror is now authoritative and grid_in is built from it — so the assertions moved with it: the asker's mirror names "Second", the document's default is still "Default", and the rows it is sent are the ones "Second" has. The fixture's two views are transposes of each other, so "was I sent the right grid" cannot be answered by accident.

-

Two faults injected 2026-09-20, both of which fail it. Pointing grid_in at app.view_name() regardless of the session reproduces the original defect exactly. Removing ClientSession's projected_view observation is the new way to reproduce it: a switch is ViewOnly, so nothing in Dirty carries it and the session must notice its own move or never rebuild its grid at all.

-

#Helper — app_with_two_views

-

Not a test — the fixture for every lockstep and rebase test in this file.

-

A second view whose axes are the first one's transposed, so the two grids cannot be confused for each other: "Default" rows are regions, "Second" rows are measures.

-

#a_view_switch_moves_only_the_switchers_mirror

-

The successor to a_view_switch_moves_every_sessions_mirror_not_just_the_switchers, which pinned the lockstep improvise-jytm removes. The model is shared and the UI is not, so a bystander keeps its view, its scroll and its grid.

-

Three assertions, because the old bug can come back through three different doors: the bystander's mirrored active_view, its row_offset (nonzero, so a mirror reset shows as a move rather than coinciding with the origin), and whether project_all names it at all. That third one is the sharpest — a bystander repainted for somebody else's switch is invisible in the first two, since it would be repainted with its own view's content and look right.

-

The scroll is drained through project_all before the switch, so the fan-out the test reads names only what the switch caused.

-

Fault injected 2026-09-20: restoring the adopt_active_view loop (push the accepted view into every mirror) fails it, as does removing the projected_view observation.

-

#a_refused_switch_corrects_the_asker_and_nobody_else

-

improvise-6c6q. apply_switch relayed Broadcast::ToOthers, so on the refused path every client but the one that was wrong was told the accepted name. The asker's own slice kept the name it had folded optimistically while its server-side mirror held the accepted one — gotcha 24's mismatch, arriving through the asker's own keystroke. apply_switch's doc comment claimed the hole was closed, which is worse than the hole.

-

The test drives the asker's own ViewState, folding the command the way a real client would before handing it to the door. That is the whole cost of the ticket: the sibling bug in apply_model shipped green because its test read a "what do the others hear" helper, which looks away from the asker by construction. There is deliberately no such helper in this test.

-

The bystander is asserted from a view it can be moved OFF. A first version left it on the default, where a wrongly-addressed relay folds to a no-op — so the assertion read as coverage and was vacuous, which review demonstrated by injecting ToAll and watching this test pass. It now sits on "Second", and the audience itself is asserted (Broadcast::To(asker, …)) rather than inferred from what somebody's slice folded to: the audience is the message (gotcha 36), so it is the thing to read.

-

Two faults injected 2026-09-20, both of which fail it: relaying ToOthers (the original improvise-6c6q bug) and relaying ToAll (the over-correction). The second also fails folding_the_relay_puts_a_refused_asker_back, which reads frames rather than folded state.

-

#deleting_the_view_this_client_is_on_moves_it_to_the_default

-

The host-side half of deleting_a_view_moves_the_client_that_was_on_it_and_tells_it, and a desync that predates improvise-jytm: Workbook::delete_view moved the document's active view and nothing moved ViewState::active_view, so the TUI's cursor was read under a key naming a view that was gone. It was invisible while draw_status read the workbook's copy; improvise-k69y makes it read the client's, which is what turns it into a wrong badge.

-

The negative arm is the discriminating one: deleting a different view must leave this client where it is. Without it, unconditionally seating the client on the default would pass the positive assertion. The fixture therefore carries three views, and the first delete is of the one nobody is on.

-

Fault injected 2026-09-20: dropping the view_state.active_view line from effect::DeleteView fails it.

-

#deleting_a_view_moves_the_client_that_was_on_it_and_tells_it

-

A hole improvise-jytm opens and must therefore close. Before the split, Workbook::delete_view moved the document's active view and adopt_active_view pushed the result into every mirror, so deleting the view a client was on moved that client. The split removes both, and without adopt_deleted_views the client is left naming a view the document has not got: App::resolve_view falls back to the default, so it is projected the default's grid while its slice says otherwise, and viewport() reads its cursor under a key nothing will ever write again.

-

The fixture carries three views and the bystander sits on the one the delete does not remove. That is the whole discrimination: a first version left the bystander on the default, where a relay addressed to everybody folds to a no-op, and review demonstrated the vacuity by replacing Broadcast::To with ToAll and watching the test pass. The evidence entry had even stated the risk in one sentence and claimed the assertion covered it in the next — the exact shape this file exists to stop.

-

So four assertions, over three different surfaces. The audience is read off the Broadcast itself, because the audience is the message (gotcha 36). The client that lost its view moved, in its mirror and in its own slice, the latter driven by folding what the host relayed. And the bystander is still on "Third" — in its slice and in its mirror — which only means something because there is somewhere for a wrong relay to have moved it from.

-

The deletion itself is asserted, because Workbook::delete_view refuses to remove the last view: a fixture with one view would pass every other line here while deleting nothing.

-

Two faults injected 2026-09-20, both of which fail it: dropping the adopt_deleted_views call from apply_model, and addressing its relay ToAll.

-

#a_session_on_another_view_is_projected_that_views_axes

-

The hole review found in improvise-jytm's first cut: only the grid half of "every projection follows the session's view" was checked. category_tree_in and tile_bar_in are reached through ClientSession::view_in, and replacing that function's body with app.view() — the host's view — left the whole suite green.

-

The harm is worse than a wrong pixel. A category's axis is what the tile bar is for: it is the control a user reassigns axes with. Under the fault a client on another view sees the wrong axis for every category, in the widget it would use to change them, while the grid beside it is correct — nothing to notice and nothing that fails.

-

app_with_two_views is what makes two assertions enough: "Second" is "Default" transposed, so reading the wrong view swaps Row and Column rather than blurring them, and the test names both categories rather than one.

-

Fault injected 2026-09-20: ClientSession::view_in returning app.view() fails it.

-

#walking_the_view_history_moves_no_document_state

-

The other hole review found: of the three effects improvise-jytm reclassified to ViewOnly, only SwitchView had a guard (a_wire_view_switch_leaves_the_document_clean). Putting ViewBack or ViewForward back to Model(LayoutGeometry) left all 1313 tests green — which is gotcha 3's whole point: a mis-declared kind is silent.

-

It asserts the consequences rather than the declaration, because those are what a reader can price. App::apply_effects raises model_state.dirty for any Model(_), and the browser worker persists on that flag — so a mis-declared view-back rewrites the entire OPFS document (gotcha 41: Storage is whole-document) for a move that changed nothing. And it bumps layout_version, so every other client's next Command::Model is refused LayoutMoved (ADR 001) because somebody pressed a navigation key.

-

The assert_ne! on the active view before each pair of assertions is the check-the-checker: an empty history stack makes ViewBack a no-op, and a no-op dirties nothing whatever its kind.

-

Both effects are driven, because they are separate kind() implementations and review injected them separately.

-

#a_fresh_session_opens_the_documents_default_view

-

The other half of improvise-jytm's contract, and the reason Workbook::active_view survives at all: it is what a freshly connected client opens. The fixture switches an existing session first, so a connect that read any client's view rather than the document's would be visible.

-

Fault injected 2026-09-20: pushing the accepted view into every mirror in apply_switch moves the document's default nowhere, but making apply_switch write workbook.active_view fails it.

-

#Helper — diff

-

Not a test — the accessor every diff-subject test in this file reads its answer through.

-

The two halves of an Applied::Diff, for the tests whose subject is not the rebase. It panics rather than defaulting, so a command that starts replacing the document is a loud failure here and not a silent Dirty::NOTHING.

-

Note: before the comment-cap wave this helper's doc comment was preceded by an orphaned paragraph ("One connected client, as its host really drives it…") describing a type that no longer exists at that site. Dropped.

-

Both a_model_command_that_moves_the_active_view_binds_every_mirror and a_model_commands_view_switch_reaches_the_asker_too were retired by improvise-jytm, along with the to_others helper whose blind spot the second one existed to cover. No ModelCommand moves anybody's active view any more: ViewBack/ViewForward left the wire because the stack they pop is one client's history, and a document swap lands in the rebase arm. The property the second test defended — a correction must reach the client that folded it — is now a_refused_switch_corrects_the_asker_and_nobody_else, which drives the asker's own slice rather than a helper.

-

#a_geometry_command_invalidates_every_clients_cursor_including_the_askers

-

TransposeAxes and CycleAxis call app.reset_cursor() because renumbering every row and column makes the old indices name different cells (gotcha 24) — but that reset only reached the authoritative App's own view slice. Each ClientSession keeps its own cursor in its mirrored ViewState, and nothing reset those, so every client was left indexing a grid that had changed shape under it: Viewport::window clips, so what a bystander actually saw was a blank grid, until it happened to scroll (improvise-gvnb).

-

The asker is checked alongside the bystander, and that is not symmetry for its own sake. A Command::Model is the one kind a client cannot fold optimistically, so the asker is in exactly the same position as everyone else, which is why the reset is Broadcast::ToAll and not the To(id, …) a refused SwitchView correction is.

-

#a_model_command_that_does_not_reshape_the_grid_leaves_cursors_alone

-

The other arm. A model command that leaves the grid's shape alone must not throw anyone's cursor away: where a client is looking is its own UI, and only a change to the shared geometry may invalidate it. Without this, "reset on every model command" would pass the test above.

-

#a_bystanders_own_slice_follows_the_invalidation

-

And the bystander really is shown the new grid from the origin, rather than a window clipped to nothing. This is the half the mirror reset alone cannot give: the client's own slice is driven by what the host relays, so the test drives it the way Host::command does.

-

#Helper — host_dispatch

-

Not a test — the relay stand-in that test and its siblings drive a client's own slice with.

-

A stand-in for Host::command's relay, driving one client's own slice. to is the client being driven and from the one that asked, because that pair is exactly what Broadcast's audience is about.

-

#a_model_command_that_leaves_the_active_view_alone_broadcasts_no_switch

-

Renamed in review: it was ..._broadcasts_nothing, and since improvise-9mo9 it asserts a ResetCursor broadcast, so the name read as a lie about its own assertion. No client folded an active view, so there is no SwitchView correction to make. What the others are told is the Dirty it produced — and, since improvise-9mo9, the ResetCursor an AddCategory owes, because a new category lands on an axis in every view. The assertion is the exact broadcast list rather than "contains no SwitchView": a weaker form would go on passing if the cursor invalidation were dropped again.

-

#every_reshaping_model_command_invalidates_every_cursor

-

Only TransposeAxes and CycleAxis reset the cursor, while SetAxis, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, AddCategory, RemoveCategory, AddItem, AddItemInGroup and RemoveItem renumbered the grid and did not (improvise-9mo9). SetAxis is the case that shows the shape of the bug: CycleAxis is SetAxis to Axis::next(), so a pair differing only by an argument disagreed. Because reset_cursor is also the only writer of cursor_epoch, every one of these left the host's own cursor stale and broadcast nothing, which gotcha 24 records as strictly worse than doing neither — the diff then believes each client already holds origin-positioned content and sends nothing, so they stay stale permanently rather than until the next scroll.

-

Two faults were injected. Deleting app.reset_cursor() from SetAxis fails the bystander-cursor assertion (left: 1, right: 0). Changing the fixture so the item ShowItem reveals was never hidden fails the first assertion — "ShowItem reshaped nothing, so this case is not evidence of anything" — which is the one that stops this from becoming a table of commands asserted against a grid that did not move. That guard matters most for AddItem: the fixture puts two categories on Row precisely so an append to the inner one inserts a row inside every outer block, because on a single-level axis an append lands at the end and moves nothing, and a one-level fixture would have made the reset look unearned.

-

#Helper — app_with_a_reshapeable_grid

-

Not a test. Two categories on Row, one of them grouped, one item hidden and one item left with no data — so ShowItem has something to reveal, ToggleGroup has a group to fold and TogglePruneEmpty has an empty row to prune. Every command in the table has something real to reshape, which is what the first assertion checks.

-

#drilling_resets_only_the_drill_views_cursor

-

improvise-9mo9 made SetAxis call app.reset_cursor(), and DrillIntoCell issues eight or so SetAxis effects to build the _Drill view. The PR claimed that was safe and did not test it — the claim was that SwitchView precedes the batch, so reset_cursor writes the active view's cursor, which by then is the freshly created _Drill one, while the view drilled from keeps its entry in ViewState::cursors. That is an ordering argument about effects inside a batch, which is exactly the class of claim gotcha 23 exists because people get wrong.

-

Two faults injected. Removing the SwitchView from DrillIntoCell's batch fails the first assertion — "the drill did not switch views, so this proves nothing about per-view cursors" — which is the guard that stops the test passing for the wrong reason if the drill ever stops switching. Changing reset_cursor to clear the whole cursors map rather than the active entry fails the third: left: (0, 0), right: (1, 1), the cursor in the origin view thrown away.

-

It asserts both sides on purpose. That the _Drill view opens at its origin is half the property; that the other view keeps (1, 1) and row_offset: 1 is the half that would have broken, and a test asserting only the first would pass against a map that had been emptied.

-

#an_accepted_no_op_invalidates_no_cursor

-

The third negative arm, found in review. Gating the reset on Ok is not the same as gating it on "something changed", and six commands the document accepts changed nothing and reset every cursor anyway: SetAxis to the axis already held, HideItem on an already-hidden item, ShowItem on a never-hidden one, AddItem of an existing item (Category::add_item dedupes and returns Ok), and RemoveItem/RemoveCategory of something absent.

-

The first assertion is the non-vacuity guard and it is doing real work here: it uses grid_shape to prove each case really is a no-op, so a fixture drifting into one where the command does reshape fails rather than passes.

-

Review then found five more cases the table could not reach by construction, because app_with_a_reshapeable_grid had no formula, no category on Axis::None and no unknown-name case — the one-category-fixture shape the doctrine names. The fixture now carries a Profit formula, a Channel category parked on Axis::None, and a Ghost entry hidden under a name the model never had, and the table covers: SetAxis on an unregistered category, HideItem/ShowItem on a name the grid never drew, ToggleGroup on a group that does not exist, and AddItem { _Measure, Profit }.

-

That last one is the one to remember. The guard it replaced read category.items, but _Measure's effective items are explicit ∪ formula targets (gotcha 7), so it called a drawn column "new". Two of the five injections initially failed to reproduce, and both times the injection was wrong rather than the guard: the ShowItem case needed a hidden ghost name, since is_hidden is false for a name nobody hid; and the AddItem case had to compute its predicate before the add, because afterwards the item really is in category.items. An injection that does not reproduce the original ordering tests nothing — the same shape as the simulated-fix confirmation in gotcha 48.

-

ToggleGroup's first guard was also wrong and this test caught it: add_item_in_group sets item.group without pushing to Category::groups, so asking groups said "no such group" about the fixture's real one.

-

Faults injected, two, both caught by name: removing SetAxis's moved check, and removing AddItem's fresh check. The TUI repro needs no test harness — TileSelect's r emits SetAxis with no comparison against the current axis.

-

#naming_a_measure_at_the_bottom_right_leaves_the_cursor_put

-

A regression this PR introduced and review caught. AddItem gained a reset_cursor, and CommitAndAdvance{Down} on the trailing + column routes RecordCol::NewMeasure through add_measure → effect::AddItem. inserts_a_column matches only NewCategory, so NewMeasure falls through to the advance — and EnterAdvance at the bottom-right returns AbortChain, emitting no SetSelected. Nothing restored the cursor. The SetSelected repair added for +cat covered that arm only.

-

The fix moves the restore before the advance, which is gotcha 21's rule (tidy-up goes where an abort cannot reach it) applied to the cursor rather than to the mode. Injecting the old ordering fails it: left: (0, 0), right: (0, 3).

-

#a_refused_model_command_invalidates_no_cursor

-

The second negative arm, and the one the first version of improvise-9mo9's fix got wrong. It was a loop of one until review: it named a class and tested AddItem alone, and injecting reset_cursor() into AddCategory's Err arm — the very bug it was written for, one effect over — left it green. It is now a table over all three refusable effects, and that injection fails it by name. AddItem and AddItemInGroup report a failure — an unknown category — through status_msg, and the reset was written after the if let Err, so a refused add bumped cursor_epoch and broadcast Command::ResetCursor to every client for a change the document never made. AddCategory had been gated on success in the same commit, which is what made the inconsistency visible.

-

SetNumberFormat's arm cannot see this: it tests a command that succeeds and reshapes nothing, where this tests one that reshapes nothing because it was refused. Injecting the fault — moving the reset back outside the match — fails it on the bystander's cursor. The first assertion is the non-vacuity guard: if the category ever became valid, the case would stop being about a refusal and the test would say so rather than pass.

-

#Helper — grid_shape

-

Not a test. The row_items/col_items of the layout, as text: which cell each position names. It is compared before and after rather than counted, because a cats change can re-identify every position without changing how many there are.

-

#a_clients_view_only_command_moves_only_that_clients_mirror

-

The other half of the door: a command that is nobody else's business must stay that way. A client's scroll must not move the authoritative App's own view slice — the model is shared, the UI is not.

-

#a_document_swap_reseeds_every_session

-

Replacing the document must clear the cursor map, because the incoming document's view names have nothing to do with the outgoing one's (gotcha 24). install_workbook does that for the App's own view slice, but nothing touched the session mirrors: a connected client went on naming the old document's view and scrolling by the old document's cursor, so it was projected a window past the end of a document it had never seen — an empty grid, with nothing that would ever bring it back.

-

#Helper — other_document

-

Not a test — that test's incoming document, and the one whose rename hides a bug.

-

A different document, with a differently-named active view, so a mirror still keyed to the outgoing one is visible rather than coincidentally right.

-

#rebase_hands_every_session_a_snapshot_of_the_new_document

-

The reseed is reached from the projection path itself, not from a hook a host must remember to call — Sessions is not reachable from the effect that installs a workbook, so a convention there would be unenforceable. rebase exists for the other half: the client's own view slice lives on the client, and only a fresh Snapshot resets it.

-

#a_document_swap_over_the_wire_rebases_every_client

-

A document swap asked for by one client must reach every other client's own view slice, and the host must not be able to miss it.

-

Command::Model put the WholeWorkbook mutations on the wire (improvise-kmgs), so LoadModel is now something another client can ask for. The server stayed honest — the generation bump reseeds every mirror on the next project — but a bystander went on holding cursors["Default"] = row_offset 3 from a document that no longer exists, and Applied { dirty, broadcast } said nothing that would tell its host to send a Snapshot. Sessions::rebase, the only thing that produces one, had no non-test caller in the tree.

-

That is gotcha 24's cursor-vs-geometry mismatch, arriving through another client's command — the same shape doctrine(lockstep_binds_every_session_not_the_asker) names, one layer out: the obligation was addressed to a host in prose instead of being carried by the value it gets back.

-

The server's own mirror self-heals off the generation bump (a_document_swap_reseeds_every_session covers that half); this is the other one, the client's slice, which only a Snapshot resets.

-

#Helper — a_document_shaped_differently_under_the_same_view_name

-

Not a test — the fixture that test needs instead of other_document.

-

A different document whose active view has the same name as the outgoing one, and a different shape: one row where the outgoing document had three.

-

other_document renames the active view, and that rename is what makes the bug below invisible: apply_model broadcasts a SwitchView when the name moves, a client folds it, and the stale cursor is then read under a key nothing has written yet — right by accident. Same name, different geometry is the case with nothing to hide behind, and it is the ordinary one: two revisions of a document both call their first view Default. (Gotcha 39's fixture warning.)

-

#Helper — app_over_a_loadable_document

-

Not a test — the App that makes a wire LoadModel in that test really replace the document.

-

An App whose storage already holds that document, so a Model(LoadModel) arriving over the wire really does replace the document rather than setting an error status.

-

#a_command_that_leaves_the_document_in_place_is_not_a_rebase

-

The other arm of the same rule, so the check is not one that always says "rebase" (doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)). A model command that mutates the document in place, and a view-only one, are both diffs — the question apply_model asks is whether the generation moved, not which variant arrived.

-

#ui::project::tests::a_load_that_fails_is_not_a_rebase

-

Qualified because ui::host::tests has a test of the same name: this is the apply_model one, and a bare leaf was satisfiable by either.

-

A LoadModel naming a document the storage does not hold leaves the document alone, so it is a diff too. This is why apply_model asks the generation rather than the variant: "is this one of the three WholeWorkbook commands" is not the same question as "was the document replaced", and a client would be reseeded onto the document it was already holding, losing its cursors to a failed load.

-

#Note — the cell_identity module

-

Emitting and resolving the surrogate a client names a cell by (ADR 002). The two halves are tested together on purpose: an id that is emitted and never resolved, or resolved by re-deriving it the same way it was emitted, would agree with itself while agreeing with nothing else.

-

#the_two_measures_of_a_row_share_a_record_and_differ_by_measure

-

Gotcha 1's unit, projected: two measure columns of one row are one record and two cells. Both halves matter — a surrogate that also varied by measure would break record_versions, and one that did not vary by measure at all would make the two columns the same cell.

-

#a_projected_record_id_resolves_to_the_cell_it_was_projected_for

-

The round trip that makes the surrogate usable: what the server projected for a position resolves back to the key that position has. Every row is checked, because one row cannot tell "resolves correctly" from "resolves to the first record".

-

#Helper — raw_app

-

Not a test — the fixture for every stored-cell surrogate test in cell_identity.

-

Region on Row, _Measure on Column. Nothing regular sits on None, so every cell is a stored cell. Three rows and two measure columns, so "the record" cannot be confused with "the row" or with "the cell".

-

#a_projected_aggregate_id_resolves_to_the_same_constraint

-

An aggregate names a coordinate constraint, and it must come back as the same constraint — including the page and filter coordinates baked into the layout's key (gotcha 27), which is why this resolves against cell_key rather than against the row and column labels.

-

#Helper — aggregated_app

-

Not a test — the fixture for the aggregate half of cell_identity.

-

The same, with a second regular category left on None, so every visible cell sums over Product rather than being one stored cell. That is the fork EditOrDrill makes, and the one the wire projects.

-

#a_surrogate_for_a_removed_row_fails_to_resolve_and_the_others_do_not

-

The first arm of staleness. The identifiers are held constant and only the thing that makes the id stale is varied (doctrine(a_rename_can_hide_a_stale_key)): "East" is removed, so its surrogate must fail to resolve — while "North"'s, taken from the same projection, still resolves to the same cell. Without that second assertion a resolve_cell that returned None for everything would pass.

-

#an_empty_cell_can_be_written_through_its_projected_id

-

improvise-1ey is "type a number into a cell and see the value update", and the cell a user types into is usually empty — so it is named by the grid and by nothing in the store. The index cannot answer it, which is why resolution falls back to the layout, and this is the case that makes that fallback load-bearing rather than defensive.

-

#a_write_by_surrogate_lands_on_exactly_that_cell

-

Two rows and two measure columns, so a write that went to "the first cell", "the whole row" or "the whole column" is visible.

-

#a_write_through_a_dead_surrogate_changes_nothing

-

The second half of "reject, nothing to write to": a surrogate that does not resolve must apply nothing, not fall back to some other cell. The document is compared whole, because a write to the wrong place is exactly the failure being excluded.

-

It goes through model_effects rather than the wire form, because the session door reaches the same conclusion first and typed — Checked::against refuses this command with Rejection::RecordGone, so a Checked over it does not exist. What is asserted here is the floor underneath that door: the key path and any host resolving a command locally get an empty batch rather than a misdirected write.

-

#a_records_mode_cell_is_named_by_its_coordinates_not_by_its_position

-

The trap gotcha 1 sets. GridLayout::cell_key returns a synthetic (_Index, _Dim) key here, carrying positions — the very thing ADR 002 rejected _Index for, because position names a different record after a filter, an insert or a delete. The projected id must come from RecordRow::coords instead, which is what resolving it back to the real cell proves.

-

#Helper — records_app

-

Not a test — the fixture for every records-mode surrogate test.

-

Wide format: one row per coordinate tuple, a column per raw measure, the coordinate columns in front and a + column behind. Two records and two measures, so neither "the row" nor "the column" can stand in for the record by accident.

-

#the_measure_columns_of_a_record_row_share_its_record

-

Two measure columns of one row are two cells of one record, and the next row is a different record — the same shape the pivot case has, checked here because records mode reaches it by a different path.

-

#a_coordinate_column_is_projected_with_no_identity

-

A coordinate column is not a cell to write into: editing it renames that coordinate across every measure cell in the row (gotcha 1), which is a different intent from SetCell. So it is projected with no id at all, and a client cannot compose a write against it.

-

#a_write_moves_the_version_under_the_clients_own_surrogate

-

The version counter is keyed by the same surrogate the client holds, which is what lets one identity answer both ADR 002's question and ADR 001's. A write must move the counter the client would check.

-

#Helper — apply_caught_up

-

Not a test — and note it is #[cfg(test)] fn, not #[test] fn.

-

Sessions::apply for a client that is fully caught up, for the tests whose subject is not the basis. The staleness tests build the Basis by hand, so neither kind can pass by accident through the other's convenience.

-
-

#Note — the basis_check module

-

ADR 001's basis check: a batch composed against a picture of the document that has since moved is refused, and one composed against the current picture is not (improvise-xwi6, improvise-fkfg).

-

Both arms, per tier. A check nobody has watched reject is indistinguishable from one that always accepts, and a check nobody has watched accept is indistinguishable from one that always rejects (doctrine(check_the_checker)). So every tier appears twice.

-

The fixtures hold every identifier constant and vary only what makes the basis stale (doctrine(a_rename_can_hide_a_stale_key)): the record a batch names is projected once and reused, so a rejection can never be an artifact of the test naming something different the second time.

-

#a_cell_edit_is_rejected_when_the_layout_moved_under_it

-

The class-2 case, and the one test to keep if only one survives.

-

A CellData batch is stale when the layout moved, even though no record it names was written: the coordinates its key hangs off changed underneath it. This is A removing an item while B commits an edit keyed by it, and it is corruption rather than a lost keystroke — which is exactly what a cell-level lock would not have covered, because holding a cell does not stop anyone deleting the row.

-

#Helper — basis_for

-

Not a test — the claim every basis_check test moves exactly one tier of.

-

The basis a caught-up client holds for one record. Built by hand rather than through caught_up so each test can move exactly one tier of it and leave the rest true.

-

#a_cell_edit_composed_after_the_reshape_is_applied

-

The other arm of the same tier. A batch composed after the reshape is applied — so the rule is "the layout moved since you looked", not "the layout has ever moved".

-

#a_write_is_rejected_when_the_record_it_names_moved

-

Class 1: somebody wrote the record since this client read it. Data staleness — the record still resolves, so force is coherent and the reason says so. The identifiers do not move, only the value, so a rejection cannot be a renamed key in disguise.

-

#a_write_survives_another_record_moving

-

The arm that keeps the tier from being "reject whenever anything was written". A record moving does not invalidate a batch that names a different one — which is the whole reason the tier is per-record rather than a single counter.

-

#a_write_to_a_record_that_no_longer_exists_says_so

-

Class 2 by the other route, and the reason ADR 002 put a maintained index in DataStore rather than regenerating one from the layout: "the record is gone" and "the record changed" must not collapse into one answer. There is nothing to write to here, so nothing to force — force never creates structure.

-

The test brings the layout claim up to date so that, whatever kind RemoveItem wears, only the record tier is left to answer. It is CellData today — removing an item drops its cells and moves no view's axes — so that line is currently a no-op; it is kept because a reclassification to LayoutGeometry would otherwise reject on the tier above and hide the case this test is about, silently turning it into a second copy of a_cell_edit_is_rejected_when_the_layout_moved_under_it.

-

#a_claim_that_omits_the_record_it_writes_is_not_a_free_pass

-

The force-by-omission hole, closed. Which records are checked is the server's decision, read off the command, so a client that simply left its record out of the claim does not skip the tier — it is told it never stated one. Without this, ADR 001's "force is a re-composition, not a bypass" would have a bypass sitting in the message format.

-

#a_document_swap_invalidates_every_claim

-

The top tier invalidates everything, whatever the other two say. It is checked first so the answer names the largest thing that moved: a client told "a record moved" after a document swap would go looking for a record that is not there.

-

#a_rejected_batch_changes_nothing_anywhere

-

Rejection is a normal outcome: nothing is applied, nobody else hears about it, and no basis moves. A rejection that dirtied something would tell every other client to redraw for a change that did not happen.

-

#a_published_basis_follows_the_grid_updates_it_describes

-

The order ClientSession::basis_update claims is load-bearing, pinned.

-

The basis names record counters for the cells the grid updates in the same batch carry, so a Projection::Basis that overtook them would let a client claim a version for a value it had not been shown — the mirror image of gotcha 33's rule that labels precede the cells they frame, and unlike that rule it had nothing holding it but the order of two statements in project_diff. Proved by injecting the swap (gotcha 43).

-

#a_client_that_folds_the_stream_composes_an_accepted_batch

-

The check is only satisfiable because the server publishes the basis — which ADR 001 does not mention, and without which a client could only forge one. So: a client that folds what it is sent can compose a batch that is accepted, end to end, with no test-only constructor anywhere in the path.

-

#re_composing_against_the_republished_basis_is_accepted

-

And the other side of publication: after somebody else writes, the stream carries a new basis, so the client that folds it can re-compose and be accepted. That is ADR 001's force — a re-composition against the current basis — reachable without any mechanism that skips the check.

-

#ui/host.rs

-

#connecting_hands_that_client_a_snapshot_that_draws_the_document

-

AC 2: the bootstrap is sent on connect, addressed to the new client, and it is not empty — a Snapshot whose seed carried nothing would satisfy "a snapshot was sent" while leaving the client blank.

-

#the_reported_extent_bounds_the_snapshot

-

The extent is what the snapshot is built at, so a client that says it can see one row is not sent two. This is the property that makes Viewport the handshake rather than an afterthought.

-

#without_the_command_the_cell_is_empty

-

doctrine(check_the_checker): the preceding assertion would also pass if the seed happened to contain the value already. Inject the fault — never send the command — and it must fail.

-

#a_bystander_is_projected_another_clients_edit

-

A cell edit is the whole document's, so a client that did not ask for it is told about it too. The projection is per-session because the viewports differ, not because the change does.

-

#Helper — model

-

Not a test — and note it is #[cfg(test)] fn, not #[test] fn.

-

A model command from a client that is fully caught up, for the tests whose subject is not ADR 001's basis. It goes through handle like any other frame; only the basis is supplied rather than folded, and caught_up can do that because it holds the App (a client cannot). The staleness tests build their own Basis.

-

#Helper — addressed

-

Not a test — how every host.rs test names a cell the way a client would.

-

The same cell as a client names it: the opaque surrogate the server projected on CellDisplay::id, which is the only handle a client has (ADR 002). A CellKey is not constructible over there.

-

#a_scroll_reaches_only_the_client_that_scrolled

-

A view-only command reaches nobody else. The counterpart to the test above: if every command fanned out, that one would pass for the wrong reason.

-

#a_batch_of_commands_is_applied_whole_and_in_order

-

One keypress resolved on a client is up to three commands, and the host must take all of them (improvise-bpq2).

-

The fixture is the shape that actually arrives: a cursor moved past the bottom of a one-row window, so the selection and the offset that follows it travel together. The assertion is the offset, not the selection, because a host that applied only the first command would still look right on selected — the window is what goes wrong, and it goes wrong silently.

-

#a_batch_is_abandoned_at_the_first_rejection

-

A refused batch applies nothing after the refusal. The stronger half — nothing before it either — is a_refused_batch_leaves_the_commands_before_it_unapplied; this is kept because the two halves fail independently.

-

#a_refused_batch_leaves_the_commands_before_it_unapplied

-

improvise-955n / improvise-08wb. The bug: ADR 001 is emphatic that a stale batch is never applied, not even partially, and improvise-ondj went to the trouble of a type (Checked) to make the unchecked path unconstructible. But Checked is per command, and a ClientFrame::Commands is a sequence of them: Host::commands applied in order and abandoned at the first rejection, so a frame whose second command was refused had already applied its first. The guarantee was true of a single command and false of a batch.

-

The earlier command is a view command, which is not a concession: it is the shape the one real producer emits (keys::commit — one mutation among view commands), and it is still observable, because the host folds it into that client's mirror. A frame may carry more than one mutation; what it may not do is apply some of them and refuse the rest, which is clause 4 and open.

-

A refusal test needs an accepting one (doctrine(a_helper_that_builds_the_world_the_code_wants_tests_nothing): pair a refusal with an acceptance, or the refusal can pass by refusing everything). The second half runs the same leading SetSelected followed by a command whose basis is current, and requires both to land. Without it, a SetSelected that simply never worked would clear the first assertion and prove nothing at all.

-

Inside it, the stale claim is built from caught_up and then has a single tier bumped, so the refusal can only be about the tier that was moved — a hand-built basis that is stale in three ways at once cannot tell which check answered.

-

#a_later_write_in_a_chain_still_answers_for_its_own_record

-

improvise-955n. The regression 90ea26e introduced, and the reason clause 2 cannot be applied to all three tiers.

-

Hoisting the basis check to the frame hoisted all of it, so every mutation after the first was admitted with no record check at all. Two clients, one record: A composes [SetCell(North), SetCell(South)] against an honest current basis, B writes South, A's frame arrives — and A's write to South landed on top of B's silently, which is exactly the lost update ADR 001's record tier exists to prevent. At the base commit the second command was checked and the frame refused.

-

The per-command check was doing two jobs and the commit noticed one. "Is the client's picture of the document current" is a frame-level fact and hoists correctly. "Is the client's picture of this record current" is not: which records are at stake is a property of the command, which is why records_named is an exhaustive match over ModelCommand (gotcha 43). The incapability argument that motivated clause 3 is true of records a frame creates and was over-generalised to records it merely writes — and those are the ones the tier is for.

-

Three arms, because a fix that refuses everything would pass the first alone and a fix that checks nothing would pass the last two.

-

In arm 2, B moves South with a SetCell, which is CellData: neither the document nor the layout tier moves, so only the record tier can discriminate — which is what makes this a test of the record tier and not of the hoist in general.

-

Arm 3 — the same record twice in one chain. Legitimate and likely (two measures on one record is one record) and it works for a reason worth pinning rather than rediscovering: every check happens before any command is applied, so the second write is compared to the record's pre-frame counter, not to the one its own sibling just bumped. Were the checks interleaved with the applies, this frame would refuse itself with RecordMoved.

-

Read this before closing clause 4. Interleaving is tempting there, because it would close the silence: [RemoveItem(North), SetCell(North)] under interleaving is refused RecordGone instead of silently half-applying, which is half of what clause 4 owes. So check-before-apply is not a free win — it buys the legitimate double-write and sells the loud failure on a broken chain, and clause 4 has to pay that back some other way. Measured rather than argued: injecting the interleaving fails exactly two tests, this one and a_frame_whose_later_mutation_fails_is_not_yet_undone, and the second failing is the trade. Arm 3 is what stops someone taking the trade without noticing they made it.

-

#a_data_dependent_chain_lands_in_order

-

The capability the contract is for: a chain of data-dependent commands, applied in order, each seeing what the previous one left.

-

[AddItem("East"), SetCell(the East record)] is the archetype, and it is the case a per-command basis check cannot admit however it is ordered: a client cannot claim a counter for a record its own chain is about to create, because BasisCache::claim filters to records the cache has been told about. So the basis is answered once, on the first command that carries one, and the rest of the chain runs against what its predecessors left.

-

The second assertion is the one that earns the test. Asserting only that the item exists would pass against a host that applied the first command and dropped the second — which is exactly what the previous rule did, loudly, and what the pre-frame check did, silently.

-

#a_frame_whose_later_mutation_fails_is_not_yet_undone

-

Characterization of the open hole (clause 4), not an endorsement.

-

The contract says a frame is all-or-nothing. It is not yet: App::apply_effects aborts without undoing (gotcha 21 — an abort is not a rollback), so a chain whose later mutation fails leaves the earlier one standing. [RemoveItem("North"), SetCell(the North record)] is the shape: the removal lands, the write then resolves to nothing, model_effects returns an empty batch, and the frame is reported accepted having applied half of itself. Both the partial application and the silence are open.

-

This test did not pin that, and the way it failed is the repo's own signature defect sitting on the one test whose job is to fail loudly if anyone believes the hole is closed. It asserted that North was removed and that nothing was rejected — both true of any wholly successful frame. The independent reviewer retargeted the second command from the removed North to the live South, making the frame succeed completely, and the test still passed. It could not tell half-applied from fully-applied, so it would never have inverted when clause 4 landed; it would have gone on passing. The missing assertion is the one that names the hole: the second mutation did not land.

-

It is unreachable from any client here — the only producer of a multi-command frame is keys::commit, which emits one mutation among view commands — which is what makes it safe to document rather than rush.

-

This test must be inverted when clause 4 lands: all three assertions flip, because then nothing at all happens.

-

The frame claims North at its current counter, so it passes the check and fails at apply time — which is the only way to reach the hole. A frame refused by the check never applies anything and would be testing clause 2 instead.

-

#Helper — frame_basis

-

Not a test — the frame-level claim that test and the 955n tests state.

-

The basis a caught-up client would state for a frame naming regions: the two document tiers plus each named record's current counter.

-

reduce::caught_up cannot stand in for this, and the difference is exactly what a frame is: it builds the claim for one command, so a frame's records beyond the first are simply absent from it and come back RecordUnstated. A real client calls BasisCache::claim once with every record its frame names, which is what this reproduces.

-

Name only records that exist. claim omits a record the cache has never heard of; this helper always includes one, so pointed at a record that is not there it fabricates (hash, 0) — a claim no real client could compose, and precisely the input that flips the create exemption in CheckedBatch. For a record the frame is creating, leave it out, which is what a client would do.

-

#an_accepted_switch_is_relayed_to_nobody

-

The successor to a_bystander_is_sent_the_broadcast_and_the_asker_is_not, which pinned the pre-improvise-jytm relay (Copilot, PR #10). An accepted switch is now owed to nobody: the asker folded it, and no other client's view moves.

-

It asserts the bystander gets no frame at all, not merely no SwitchView. A relay that vanished while the projection fan-out still repainted every client would pass the narrower check and still be pushing one client's view change at the rest.

-

#a_geometry_command_relays_the_cursor_reset_to_the_asker_too

-

The other audience, and the reason Broadcast carries one rather than the relay deciding. A geometry change invalidates every cursor into the document (improvise-gvnb), and the asker's is not exempt: a Command::Model is the one kind a client cannot fold optimistically, so nothing has reset it locally and skipping it would leave the very client that asked positioned in a grid it no longer has.

-

#a_stale_batch_is_refused_to_the_asker_alone

-

A refused batch is answered to the asker and nobody else (ADR 001). Nothing happened, so there is nothing to tell the others — and the asker is told only which class of staleness it was, because it already holds what is current.

-

#folding_the_relay_puts_a_refused_asker_back

-

What Broadcast::To is for, asserted end to end through Host rather than through Sessions: a client that folded a view name the document refused is put back by the frame the host sends it, so the cursor its next grid is positioned by is read under a key the document has (gotcha 24).

-

The client slice is folded before the command is handed to the host, which is what makes the assertion mean anything: a test that never folded would see "Default" whatever the relay did. The bystander is asserted silent in the same test, because a ToAll correction would also put the asker back and would be wrong.

-

#a_document_swap_sends_every_client_a_fresh_snapshot

-

AC of gotcha 39: a command that replaced the document owes every client a fresh Snapshot, not a diff — including the client that did not ask.

-

#Helper — host_over_a_loadable_document

-

Not a test — the host that test and a_load_that_fails_is_not_a_rebase drive.

-

A host whose document can be replaced out from under its clients: a second document sits in the storage backend under a name LoadModel can reach.

-

#a_client_can_ask_its_host_to_save

-

improvise-368h. The bug: effect::Save is EffectKind::ViewOnly — correctly, since it writes this host's path — so the derivation that builds ModelCommand out of the EffectKind::Model effects could not reach it (gotcha 38). There was no save on the wire at all, so no client on any transport could cause its host to write: a websocket session edited the document in memory and the file on disk never moved unless a human was at a TUI that was not there.

-

The derivation was not wrong; it was complete over the mutations and saving is not one. So the variant is added rather than derived, and it carries no path: a client cannot see the host's storage, and which document a host is hosting is the host's own fact.

-

#a_save_with_nowhere_to_write_is_refused_and_told_only_to_the_asker

-

improvise-368h: the "what if there is no path" half. Not a panic and not a silent no-op — the two answers the ticket ruled out — but the vocabulary that already exists for "nothing happened and here is why", addressed to the asker alone (gotcha 39).

-

The second client is not decoration: a refusal that leaked to the bystanders would be telling them about a failure of somebody else's host-local request, and this asserts it does not.

-

#a_write_that_fails_is_refused_rather_than_reported_as_saved

-

improvise-368h: a write that was attempted and failed must not be reported as a success. Save signals failure by aborting its batch (gotcha 21), and the batch here is exactly [Save], so an abort is that failure and nothing else.

-

The fixture fails for a reason that is real rather than mocked: a PhysicalFs pointed at a directory that does not exist. It creates nothing, so the test leaves no trace on disk.

-

#every_frame_survives_a_json_round_trip

-

The wire is JSON to start with (the ticket's choice, kept), so both directions must survive a round trip through it — including the one point at which ViewState is serialized (gotcha 25).

-

The inbound half carries a payload-bearing variant on each side of the envelope (doctrine(populate_before_roundtrip)): a Viewport and a Command carrying a CellValue::Text, which is the arm the outbound half cannot reach — a text cell aggregates to nothing under a None-axis category, so it never becomes a CellDisplay.

-

Note: host.rs also carries a second a_load_that_fails_is_not_a_rebase (doctrine(check_the_checker): inject the fault the test targets — a load that does not happen — and the snapshot must not appear; a test that saw Snapshot on every command would pass a_document_swap_sends_every_client_a_fresh_snapshot). It shares its name with the ui/project.rs test of the same name, which is why there is one heading for both above.

-
-

#ui/cat_tree.rs

-

#the_tree_carries_the_virtual_categories_and_measure_includes_formula_targets

-

improvise-vjms: the panel lists the virtual categories alongside the regular ones, and _Measure's items are derived — explicit items ∪ formula targets (gotcha 7). An implementation reading category("_Measure").items passes every other assertion in the module and silently drops Profit, which is the failure improvise-ete was closed for proposing.

-
-

#command/keymap.rs

-

#f1_and_question_mark_both_open_help

-

improvise-th7m: F1 was bound twice on consecutive lines — first to a bare enter-mode with no args, then to enter-mode ["help"]. The first was dead, because Keymap::bind inserts and the later binding wins; had it ever been the surviving one it would have failed require_args at dispatch and made F1 do nothing.

-

The risk in deleting a line of a duplicated pair is deleting the wrong one, so this pins which binding F1 is meant to carry: the same one ? carries, which is what identified it as the intended survivor.

-

#every_bound_command_name_is_registered

-

improvise-9cn: Keymap::dispatch resolves a binding through registry.interactive(name, args, ctx).ok()?, so a binding is a silent no-op key in two ways, not one: the name may not be registered, or the args may not parse. Both walks live in this test.

-

The args half matters because improvise-s13 moved bindings out of the nullary class the name check covers: enter-mode ["export-prompt"] and enter-mode ["formula-edit"] carry a mode name that parse_mode_name must accept, and a typo like "export_prompt" compiles, registers, and produces a dead Ctrl-e. (Gotcha 15.)

-

It builds the context in the mode whose keymap it is walking, because a few parsers read ctx.mode — commit-buffer item resolves its target category off AppMode::ItemAdd.

-

#prefix_keymaps_are_flat

-

improvise-jc8: an armed prefix is stored as the key that opened it and resolved against the CURRENT MODE's keymap. That is only correct while prefixes are flat — a Prefix binding nested inside a sub-keymap would be resolved against the mode map, where it does not exist, and the second half of the sequence would silently do nothing. Adding a nested prefix must fail here rather than in the terminal.

-

#every_panel_keymap_shares_the_same_spine

-

improvise-4pg: the three panel keymaps were written out separately, so a spine binding could be changed in one and forgotten in the others. They share PanelKeymap now; this pins what "the spine" is, including that each panel's cursor keys move its own cursor. (Gotcha 12.)

-

#every_mode_with_panel_toggles_binds_all_of_them

-

Every panel's toggle key is bound in Normal and in all three panel modes, so focus can move between any two and the focused one closes. A fourth panel gets all four keymaps from one Panel::ALL entry.

-

#panel_toggle_keys_dispatch_to_real_effects

-

Panel::name() has to spell what the registry's panel parser accepts. A lookup-only assertion would not catch a mismatch — dispatch swallows an unresolvable command with .ok()? — so the toggle key must come back with real effects.

-

#every_text_entry_mode_types_into_the_buffer_its_prompt_names

-

improvise-k8h: the buffer name used to be written on both sides of the crate — AppMode's MinibufferConfig names the buffer the prompt describes, and the keymap names the buffer append-char edits. If those disagree the mode prompts over one buffer while every keystroke edits another, with nothing to notice. TextEntry now reads the name off the mode, and this pins the result for every text-entry mode. (Gotcha 11.)

-

#Helper — text_entry_modes_with_exits

-

Not a test — the list that test and the two below it walk.

-

The seven modes that carry a MinibufferConfig, each with the keys that commit or leave it. SearchMode is not among them: it is Normal plus a flag, so it has no AppMode variant and no minibuffer config to agree with.

-

#every_typing_keymap_belongs_to_a_text_entry_mode

-

The list in text_entry_modes_with_exits must stay complete: every keymap that types into a buffer belongs to a mode whose minibuffer names that same buffer. Only SearchMode, which has no AppMode variant, is exempt.

-

#text_entry_modes_clear_the_buffer_they_type_into

-

Every minibuffer mode types into exactly one buffer, and every key that commits or leaves must clear that same buffer on the way — a buffer left behind pre-fills the next entry with the last one. SearchMode is excluded on purpose: it has no commit and its query is owned by exit-search-mode, not by clear-buffer.

-

The buffer is derived from each mode rather than restated in the test — every_text_entry_mode_types_into_the_buffer_its_prompt_names owns pinning what it should be (improvise-k8h).

-

#minibuffer_enter_dispatches_to_a_registered_commit

-

improvise-6t3: the three commit commands collapsed into a single commit-buffer <kind>. dispatch swallows an unresolvable command name with .ok()?, so a lookup-only assertion would still pass had the rename missed the registry — Enter has to come back with real effects.

-

#dump_every_binding

-

The instrument doctrine(binding_dump_diff) asks for: every (mode, key, modifier) lookup across all the modes, written to the file named by IMPROVISE_BINDING_DUMP.

-

Most bindings have no direct test, so a green suite is not evidence that a keymap refactor preserved them. Dump before, dump after, diff. It is inert without the variable: a test that writes a file nobody asked for is a surprise, not a check.

-

This is what proved the keymap move to improvise-protocol changed nothing — 4021 resolved lookups, byte-identical either side — and the diff was shown to be discriminating by renaming one binding and watching eight lines move.

-
-

#command/cmd/commit.rs

-

#invalid_formula_reports_the_parse_error_not_success

-

improvise-6t3: the commit pushed set_status("Formula added") after AddFormula, so the parse error that AddFormula::apply writes to the status bar was immediately overwritten. An unparseable formula was dropped while the UI reported success.

-

The add effect is the only party that knows whether it worked, so it has to get the last word on the status line.

-

#commit_and_advance_threads_edit_mode_to_enter_edit_at_cursor

-

CommitAndAdvance must thread its edit_mode through to the trailing EnterEditAtCursor effect so the post-commit re-edit lands in the mode the keymap requested. The command never reads ctx.mode.

-

#commit_save_writes_the_model_to_the_typed_path

-

improvise-lae: the save prompt has to commit to SaveAs and not to ExportCsv, which is what makes it a separate mode from ExportPrompt rather than a second binding onto it — ExportCsv would write CSV under whatever .improv name the user typed.

-

SaveAs and not Save, because Save writes the path the document already has, and the whole reason this prompt exists is that there isn't one.

-

#commit_save_on_an_empty_path_writes_nothing

-

An empty prompt must not reach SaveAs: SaveAs(PathBuf::from("")) asks the backend to write to "", and the user then reads whatever I/O error that produces instead of "you typed nothing".

-

#commit_export_bails_on_an_empty_path

-

improvise-z05r's first arm. CommitExport did not bail on an empty path where CommitBuffer's three kinds all do (gotcha 20: buffer to trim to bail-on-empty to dirty to status is the shared spine), so Enter on an empty export prompt asked the backend to write to "" and the user read whatever I/O error that produced.

-

The same change reorders the batch: the mode change now comes first, because ExportCsv aborts on a failed write as of z05r's other arm, and an abort at the tail would strand the user in ExportPrompt — where draw_bottom_bar renders the minibuffer instead of the status line, so the export error is never drawn at all and the operation appears to do nothing (gotcha 21). That ordering is asserted in commit_export_produces_export_and_normal_mode, by index and not by presence.

-
-

#command/cmd/fit.rs

-

#the_command_installs_a_formula_the_document_evaluates

-

The end-to-end claim of improvise-vx3, asserted against a real document: a command fits, the formula lands through the ordinary add_formula path, and the new measure evaluates to numbers.

-

#Helper — workbook

-

Not a test — the document every fit.rs test fits against.

-

A document a tree can learn exactly — Score steps at Cost 50 — in the units a real document uses.

-

Every category is a Label, so the only feature is the numeric measure Cost and the champion cannot reach an unresolvable one. An earlier version left Region regular with one item and claimed simplify would prune the constant one-hot; it does not, because build_categorical records (0.0, 1.0) for every one-hot whatever the column holds — so these tests were passing on seed luck (Copilot on PR #25). See improvise_gp::fit's own fixture for the same correction.

-

#the_status_line_reports_quality_against_the_baseline

-

improvise-vx3 is emphatic that the rule alone is not the deliverable: a tidy tree that generalizes no better than the mean is worse than useless. So the status line carries the held-out comparison, what the rule leans on, and the seed that reproduces it.

-

#a_refusal_names_what_went_wrong_and_stores_nothing

-

A refusal reaches the user whole. "fit failed" is not actionable; naming the measure that does not exist is (improvise-8xhy's argument, arriving at the place a user meets it).

- -

Gotcha 37b: the seed is half of what reproduces a fit, so it is an argument — and a different one really does search differently.

-

It asserts on the rule, never on the batch's debug string. The first version compared effects_debug over the whole vec, which was vacuous: status_line prints seed {:#x}, so the two strings differ because the seed was printed, and fit_measure could have ignored request.seed entirely with the test still green (doctrine(check_the_checker)).

-

AddFormula has exactly two fields — the formula text and the target category — and the seed reaches neither, so its Debug cannot differ for the reason the batch's did. (A substring guard was tried and is wrong: the seeds 1 and 99 occur inside perfectly ordinary thresholds.)

-

#the_seed_the_status_line_prints_is_a_seed_this_command_accepts

-

Copilot on PR #25. The status line advertises the seed as {:#x} and tells the user that is what reproduces the fit, so the one instruction it gives has to work on the value it just showed them — a decimal-only parser made copying it back an error.

-

Asserted as a round trip through the status line, not against a hand-written "0x…": a literal would still pass if status_line changed how it spells the number, which is the half that breaks.

-
-

#command/cmd/grid.rs

-

#drill_into_formula_cell_returns_data_records

-

Drilling into a formula cell (Profit = Revenue - Cost) must return the underlying data records, not an empty result set. The formula target coordinate is stripped from the drill key so that matching_cells finds the raw data backing the formula (gotcha 10).

-

#drilling_fixes_its_coordinates_on_filter_not_page

-

improvise-dz8: a drilled coordinate is a constraint of the drill, not a dimension the user may page through, so it lands on Filter. It used to land on Page, which put it in the tile bar and under [/] as if the user could move it (gotcha 27).

-
-

#command/cmd/mode.rs

-

#enter_mode_export_prompt_sets_mode_and_clears_its_buffer

-

The enter-export-prompt shim is gone (improvise-s13); the generic enter-mode carries the mode name instead, and clears the prompt's buffer on the way in.

-

#enter_mode_clears_a_search_keyed_minibuffer_through_write_buffer

-

improvise-gdl made write_buffer the single accessor for a runtime-named buffer, because "search" is not an entry in the buffers map but its own ViewState field. EnterMode built a SetBuffer directly, so a mode whose buffer_key is "search" would clear a map entry nobody reads and leave the previous query on screen — the exact desync gdl closed. No shipped AppMode names that buffer yet, so the mode here is constructed by hand; that is what makes the test a demonstration rather than a restatement.

-

#edit_or_drill_passes_records_editing_mode_through

-

EditOrDrill must trust its edit_mode parameter rather than checking ctx.mode — the records-normal keymap supplies records-editing, but the command itself never inspects the runtime mode. This is the parallel of the deleted enter_edit_mode_produces_editing_mode test for the records branch.

-

#enter_edit_at_cursor_cmd_passes_target_mode_to_effect

-

EnterEditAtCursorCmd must hand its target_mode straight through to the EnterEditAtCursor effect — the keymap (records o sequence or commit-and-advance) decides; the command never inspects ctx. It fronts the pair with RebuildLayout so the cursor is read against a layout that includes whatever ran earlier in the sequence (improvise-ehz, gotcha 23).

-
-

#command/cmd/navigation.rs

-

#page_cycling_skips_filter_coordinates

-

improvise-dz8: [ and ] page through Page-axis categories. A coordinate the drill fixed sits on Filter and must be invisible to them, or the user could page out of the drill they are inside.

-

#enter_advance_at_bottom_right_emits_abort_chain

-

At bottom-right EnterAdvance has no place to go, so it emits a single AbortChain effect. Trailing effects in a CommitAndAdvance batch (EnterEditAtCursor) are then skipped, which is how "Enter at bottom-right commits and exits editing" is realised.

-
-

#command/cmd/panel.rs

-

#cycle_panel_focus_advances_from_formula_to_category

-

Bug improvise-dqn: CyclePanelFocus always picked the first open panel, ignoring the currently focused panel. With formula + category open and focus on the formula panel, cycling must move to the category panel.

-

#create_and_switch_view_names_incrementally

-

improvise-aaw asked for this command to be replaced by a keymap Binding::Sequence [create-view X, switch-view X, enter-mode normal]. It cannot be: a Sequence carries literal args, and the name here is computed from ctx — so a Sequence would hard-code one name and the second n in the view panel would collide with the first.

-

The test was called ..._names_incrementally and asserted only that CreateView/SwitchView/Normal appeared — it never looked at the name, which is the one thing that makes this command a command and not a composition. Now it does, at two different view counts, because a single count cannot tell "derived from the workbook" from "constant".

-
-

#command/cmd/registry.rs

-

#every_entry_constructs_a_command_with_its_registered_name

-

improvise-9cn / improvise-61f: every registration derives its name from a prototype instance, so a typo in the name is impossible. What the types do not catch is the commands whose name() branches on a field — ViewNavigate, TileAxisOp, ToggleGroupAtCursor, CommitAndAdvance. Their prototype and the value their constructor builds are the same type, so registering ViewNavigate { forward: true } while building forward: false compiles cleanly and files the command under the wrong name, where dispatch silently misses. Constructing every entry both ways and comparing names is the only check that reaches that class. (Gotcha 15.)

-

Some entries are deliberately one-path — commit-and-advance-right reads the edit buffer and cursor, so its parse fn always errors — but every entry must be reachable by at least one path, or its name goes unchecked.

-

#registered_names_are_unique

-

A variant mix-up does not show up as a name mismatch — it shows up as a collision. Registering ViewNavigate { forward: false } where forward: true belongs files a second entry under "view-back" and removes "view-forward" from the registry entirely; parse takes the first match, so every surviving entry still agrees with its own name. Uniqueness is what catches it.

-

#the_context_reports_each_panels_own_open_flag

-

improvise-y4xg and improvise-qsmp, two shapes of one defect. registry.rs mapped Panel to a CmdContext field in two places, and App::cmd_context copies the three flags out of ViewState in a third. Both were demonstrated unguarded: swapping Formula and Category at the two registry sites left the workspace at 1400 passing, and hardcoding the category_panel_open propagator to true left it at 1394.

-

Nothing exercised either, because cmd/mod.rs::test_helpers sets the panel flags explicitly on the context it builds — the command tests construct the value they then assert on, so no test ever drove the real propagator.

-

The registry mapping is gone: both sites call CmdContext::panel_open(panel), the one mapping on this side of the wall. That leaves two permutable sites, and the test is aimed at both from a path neither controls — it writes view_state's three fields by name and reads back through the enum accessor on a context built by the real App::cmd_context. PR #70's review caught the weaker shape of this: a panel test that zeroed and read through the same four accessors passed a consistent permutation.

-

Injected fault: swap Panel::Formula and Panel::Category in panel_open. Fails. Injected fault: hardcode the category_panel_open propagator to true. Fails.

-

#toggle_panel_visibility_reads_the_panel_it_names

-

The behavioural half, and the one that reaches the registry. Its interactive arm passes currently_open straight through, so with exactly one panel open the command built for each of the three names carries its own panel's flag. Both injections above fail here too.

-

#a_focused_open_panel_closes_and_a_closed_one_opens

-

improvise-qsmp's own scenario: toggle-panel-and-focus decides close only when the panel is both open and focused, so the case that separates a right propagator from a stuck-true one needs the mode set to the panel's own. With the flag stuck true, pressing a panel's key on a closed panel decides to close it and the panel becomes unopenable by its own key — which is what this test's second arm names.

-

#Helper — open_only

-

Not a test. It spells formula_panel_open, category_panel_open and view_panel_open as fields rather than looping over Panel::ALL through an accessor, and that is the whole evidentiary value: a permutation in any mapping between the Panel enum and those three booleans has to disagree with this helper.

-
-

#command/cmd/text_buffer.rs

-

#writing_the_search_buffer_targets_the_search_query

-

Writing the "search" buffer means writing the search query, and write_buffer is the one place that knows it (improvise-gdl). Every buffer-writing command routes through it, so clear-buffer search clears the query rather than parking an unread entry in the map.

-

#colon_wq_saves_then_quits

-

improvise-git proposed deleting the SaveAndQuit command and binding ZZ to a Binding::Sequence [w, force-quit] instead — the repo's "do not name a command AAndB" principle.

-

That premise is incomplete, and this test is why. wq is not only a keybinding: it is a registered command name, so :wq reaches it through the registry, and Quit's own unsaved-changes message advertises it ("Use :q! to force quit or :wq to save+quit"). A keymap Sequence is not reachable from the : line, so deleting the command deletes the ex-command with it.

-

Keeping the command and adding the Sequence would leave two independently-written spellings of "save then quit", which is the duplication this codebase keeps curing rather than creating. So this pins the surface, and the conversion is declined on it.

-

#colon_enter_edit_at_cursor_is_not_snapped_back_to_normal

-

improvise-6sq: ExecuteCommand appends change_mode(Normal) unless some effect reports changes_mode(). EnterEditAtCursor sets view_state.mode but did not override it, so :enter-edit-at-cursor editing produced [RebuildLayout, EnterEditAtCursor(Editing), ChangeMode(Normal)] — the trailing snap-back undid the edit mode and the user never got a cursor in the minibuffer.

-

Asserting on changes_mode() alone is not enough here: the appended ChangeMode(Normal) reports true itself, so the assertion has to name the snap-back it is ruling out.

-

#colon_import_stays_in_the_wizard_on_success

-

improvise-toc: StartImportWizard swaps the mode only when the file parses, so a per-type changes_mode() cannot describe it. Reporting false made ExecuteCommand append a return-to-Normal on top of the success path: :import d.json opened the wizard and was immediately snapped back, leaving the wizard drawn under the Normal keymap. (Gotcha 31.)

-

#colon_import_falls_back_to_normal_on_failure

-

The other half of the same bug: reporting true unconditionally would strand the user in CommandMode when the import fails. A failed :import must still fall back to Normal.

-

#colon_view_back_respects_whether_history_exists

-

The ViewBack half of improvise-toc: it restores the stored frame's mode only when the back-stack is non-empty. With history present, :view-back must keep the mode it restored; with an empty stack it falls through to Normal like any other no-op command.

-
-

#command/cmd/wizard.rs

-

#name_model_enter_builds_the_model

-

Enter on the name step builds the workbook into the wizard rather than advancing the step machine; every other printable key edits the name (gotcha 22).

-

#done_confirms_on_enter_and_steps_back_on_esc

-

On the confirmation step Enter installs the built workbook and Esc steps back — it does not throw the import away, and no key closes the wizard outright (improvise-907, from improvise-c6j).

-

#a_formula_owned_cell_is_projected_as_derived_and_its_siblings_are_not

-

improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether a regular category sat on Axis::None; it never asked whether the _Measure coordinate named a formula target. So a formula cell was a Record, render.rs::editable said true, and a write to it was accepted, stored and then shadowed forever by formula_cache.

-

The fault injected: replacing the derived lookup with None, which is the pre-fix code exactly. The test then reports Record(RecordCell { measure: Some("Profit") }) where it wants Derived { target: "Profit" }. The siblings are asserted in the same test so a producer that answered Derived for every cell could not pass.

-

#an_aggregating_formula_cell_is_still_projected_as_an_aggregate

-

The negative arm of the same change, and the one a careless fix breaks: gotcha 10 says drilling into a formula cell is supported, so Aggregate must still win where the cell aggregates. Proved discriminating by returning derived before the aggregate branch — the cell then reads Derived and becomes undrillable, with nothing else in the suite noticing.

-

#edit_or_drill_refuses_a_formula_owned_cell

-

The TUI half. EditOrDrill never asked the question either, so the terminal offered the same editor over the same shadowed cell. Landing only the projection half would have made the two front ends disagree, which is the defect design-principles §5 is about. Fault injected by deleting the guard: the command returns [SetBuffer{edit}, ChangeMode(Editing)] again.

-

#a_save_clears_the_dirty_marker_on_a_client_although_nothing_is_dirty

-

improvise-z754, and the arm the whole design turns on. effect::Save is EffectKind::ViewOnly, so Dirty::of reports NOTHING — the test asserts that first, so it cannot quietly stop being the interesting case if Save is ever reclassified. A title bar gated on Dirty would therefore leave every client showing [+] for a document that has just been saved, forever, with nothing in the stream to correct it.

-

Fault injected: wrapping document_update in if !dirty.is_empty(), which is the obvious implementation and the wrong one. The test fails on the cleared arm. a_title_bar_that_did_not_move_is_not_projected is its counterweight — without it, "project the title bar every batch" would pass this one.

-

#the_title_bar_names_the_document_without_naming_the_path

-

The fixture's path is /home/someone/private/books.improv on purpose: a test whose fixture is a bare file name cannot tell file_name() from the whole path, and the failure mode is a socket server handing a remote client the host's directory layout.

-

#a_creating_column_is_named_rather_than_left_as_no_cell

-

improvise-poia. Both + and +cat projected None, which the protocol defines as "no projected cell" — the same conflation gotcha 42 names for aggregates, arriving a third time.

-

The test walks every column of a records grid and counts, rather than asserting the two creating columns alone. That matters in the direction a weaker test fails: a producer that answered Create for everything, or that made the Region coordinate column nameable, would pass a two-position check and fails this one. The fault injected — returning None for both creating columns, which is the pre-fix code — reports three nameless columns where one is allowed.

-

#a_records_coordinate_is_text_however_it_is_spelled

-

improvise-tom1. project::cell_display's records arm called records_display — which returns a String — and then inferred numeric-ness with text.parse::<f64>(). A records coordinate column holds an item name, so a Year category with items 2024/2025 projected a coordinate the browser right-aligns as a number.

-

Nothing in the suite could see it: every records fixture in the tree names its categories Region/East/North, so the guess happened to be right. The fixture here is the whole test — a category whose items are spelled like numbers — and the measure column beside it is asserted numeric in the same test, so a producer that answered false for everything would not pass.

-

Fault injected: the parse::<f64>() guess restored verbatim. It reports the coordinate as numeric.

-

#a_staged_drill_edit_reaches_its_own_session_and_no_projection

-

The user's ruling, pinned in both directions (improvise-tom1). A remote client must NOT see another client's staged drill edits, so grid_of must project the committed value; the session that staged one must still see its own typing, through App::display_value_of.

-

Asserting only the second half would pass against a producer that leaked, and asserting only the first would pass against one where staging did nothing at all — so both are here. The fault injected is the leak: teaching cell_display's records arm to prefer drill_state.pending_edits, which is the plausible "fix" this ticket exists to forbid. The projected cell then reads 999 instead of 1.

-

The fixture takes its DrillState::records from the layout the app really built, because an empty frozen snapshot silently empties the drill grid and the test then asserts nothing about a cell that does not exist — the first version did exactly that and panicked on expect.

-

#a_page_axis_carries_its_items_and_a_filter_axis_does_not

-

The producer half of improvise-p1ym, added during self-review because a_filter_axis_is_projected_but_not_cycled builds its own PageAxis by hand — a test that fabricates the very data the code under test is supposed to produce, which is this repo's characteristic defect. Only this one can say what grid_of actually emits.

-

The fixture puts Quarter on Axis::Page and Year on Axis::Filter, which is the shape a drill makes (gotcha 27), and asserts the whole projected list in one comparison so a producer that emitted items for everything or for nothing both fail. Fault injected: let navigable = true, which is filtering_cats() leaking into the protocol — the exact trap the ticket names. Year comes back carrying ["2024"].

-

#a_records_grid_has_no_column_for_a_formula_target

-

A records grid has no column for a formula target that is only a formula target, because GridLayout::records_columns builds its measure columns from Category::ordered_item_names — the explicit _Measure items — while add_formula adds none (gotcha 7). Fault injected: swapping ordered_item_names for measure_item_names in records_columns, which is a plausible tidy-up — a Profit column appears.

-

This test's original premise was FALSE, and the review caught it. It was written claiming the records branch of cell_id was safe because "a formula target lives only in measure_item_names". An item can be both an explicit _Measure item and a formula target, and improvise-poia — in the same PR — made the + column that builds exactly that reachable. So this test asserted the accident in the one configuration where the accident holds, and its own failure message named a repair for a case it could not reach. The real guards are a_records_column_a_formula_owns_is_derived_not_a_record and typing_into_a_records_formula_column_is_refused_on_the_key_path; this one now claims only what it can see.

-

#a_records_column_a_formula_owns_is_derived_not_a_record

-

The hole improvise-ir7l's first fix left, found by adversarial review. A measure can be both an explicit _Measure item and a formula target — the records + column creates precisely that — and cell_id's records branch asked formula_for about nothing, so it projected CellId::Record{measure:"Profit"}. A write there is accepted, stored, and then shadowed forever by formula_cache, which is the defect the ticket exists to close, surviving in records mode in both front ends since editable is Record-only.

-

The repair asks the document about the record's real coordinate — GridLayout::record_cell_key, the one mapping from a records position to a model cell — never the synthetic (_Index, _Dim) key, which names no measure and so can never match a formula. Fault injected: deleting the lookup, which is the pre-review code. Profit reads back as a Record. The Revenue column is asserted still writable in the same test, so a producer answering Derived for everything could not pass.

-

#typing_into_a_records_formula_column_is_refused_on_the_key_path

-

The same defect through App::handle_key and no test API at all: R, walk to +, type Profit, Enter, Esc, then i on the column just created. It exists because the projection half and the EditOrDrill half are separately breakable, and a unit test of either would have missed the other — proved by injecting each alone. Restoring cell_id while leaving CmdContext:: owning_formula reading the raw key fails on the mode assertion; the reverse fails on the identity assertion.

-

One fixture note that cost a debugging round: CommitAndAdvance re-enters editing on the cell it advances to, so the mode after Enter is RecordsEditing and an i there is a literal character. Without the Esc the test passes its mode assertion for the wrong reason — nothing happened at all.

-

#a_save_tells_the_client_the_document_is_clean

-

improvise-z754's own stated failure mode, shipped: "every client showing a [+] for a document that has been saved". Host::save applied effect::Save and returned only a possible Rejected, never projecting, so the dirty marker cleared outside any projection and no client was ever told.

-

The z754 test that was supposed to cover this called sessions.project_all by hand — which is exactly the call the production path omits. It is not a test that compares a thing with itself; it is a test that exercises a call the deployment never makes, which is the subtler shape. This one goes through Host::handle(ClientFrame::Save).

-

Two assertions guard the fixture: the edit must first report dirty: true, and app.model_state.dirty must really be false afterwards — otherwise a host that saved nothing would pass by projecting nothing. Fault injected: returning an empty vec from the success arm. No Document update is emitted.

-

#the_automatic_persist_tells_the_client_the_document_is_clean

-

The worker's half of the same defect, and a different cause: on_frame built out via frames_for and then called persist(), so the projections were built while dirty was still true. The browser is the deployment where this bites hardest, because nothing there ever sends ClientFrame::Save — the worker persists on its own behalf (gotcha 45), so this was the only path.

-

Fault injected: dropping the re-projection after persist(). The client's DocumentCache stays dirty. MainThread grew a document mirror and a projection dispatch for it, rather than a bespoke reach into the worker — the point is what a client folding the real stream ends up holding.

-

A first version asserted only !document.dirty, and DocumentCache::default() is already clean — so it could not tell "told it cleared" from "never told anything", and passed under an injection that stopped projecting the widget entirely. Found in re-review. It now records every marker the client is told and asserts [true, false] for the edit, with the bootstrap's own clean marker cleared first so the sequence is about the edit rather than the handshake. The stronger form fails under both injections.

-

#a_refused_or_duplicate_add_category_moves_no_counter

-

improvise-75ld. App::apply_effects bumped layout_version and raised model_state.dirty off the EffectKind before the abort_effects check, so the refusing effect counted itself. Two arms, and the second is the reachable one: a 13th category is refused (MAX_CATEGORIES) and aborts, while a duplicate add-cat Region returns Ok and does not abort at all — a pure no-op that still refused every other client's next Command::Model with Rejection::LayoutMoved and made the browser worker rewrite the whole OPFS document (gotcha 41).

-

Injected fault: put the two bumps back on the declared kind, inside the loop (if matches!(kind, EffectKind::Model(ModelChange::LayoutGeometry))). Both no-op arms fail; so do neither_shape_counter_moves_for_an_accepted_no_op, both_shape_counters_move_for_every_reshaping_command and a_configuration_change_that_draws_nothing_is_still_a_document_change, with 450 other tests still passing — the build ran.

-

The positive arm is not decoration: without it, "never bump anything" passes the two no-op arms. It also fails under a second injection — dropping the document_config comparison — because AddCategory is LayoutGeometry and the declared half of dirty no longer covers it.

-

#both_shape_counters_move_for_every_reshaping_command

-

improvise-pdvm. AddItem, AddItemInGroup and RemoveItem declare ModelChange::CellData and reshape the grid. Since improvise-9mo9 they call App::reset_cursor, so cursor_epoch moved while layout_version did not: a client was told its cursor was invalid while its layout basis tier still read current, and a batch composed against the old shape was still accepted.

-

It drives reshaping_commands() — the same table every_reshaping_model_command_invalidates_every_cursor drives, extracted into a shared function precisely so the two cannot answer for different cases — and asserts the grid shape really moved before asserting either counter, so a case that reshapes nothing fails loudly rather than proving nothing.

-

Injected fault: as above. Fails naming SetAxis, then AddItem once the earlier cases are removed.

-

#a_configuration_change_that_draws_nothing_is_still_a_document_change

-

The third case the shape/config split creates, and the one that forced two fingerprints rather than one. hide_item records a name whether or not the model has one, and SetNumberFormat writes the file while moving no row or column: both are dirty and neither is layout_version. A single fingerprint covering the persisted configuration made a ghost hide bump the basis; one covering only the drawn shape let a format change go unsaved.

-

Injected fault: remove the document_config comparison in observe_reshape. Fails naming HideItem { category: "Region", item: "NoSuchItem" }.

-

#a_reshape_of_one_view_leaves_a_client_on_another_view_alone

-

improvise-nynk. improvise-jytm made "which view a client is on" per-client; Sessions::adopt_cursor_epoch was not updated and still reset every mirror and returned one Broadcast::ToAll(Command::ResetCursor). A reshape of view Default therefore threw away the cursor of a client sitting on Second, which nothing reshaped.

-

The fixture is two views of one document with different axes, and the bystander switches to Second and scrolls there before the asker reshapes Default. The positive arm is kept in the same test — the asker is on the reshaped view, is reset, and is told — because without it "never invalidate anyone" passes.

-

Injected fault: drop the cursor_invalidated filter (.filter(|_| true)). Fails naming the bystander's thrown-away cursor, with 453 other tests passing.

-

#an_unknown_reshape_set_invalidates_everybody

-

The fail-safe arm of the same narrowing, tested separately because it is the half gotcha 24 calls strictly worse than doing nothing. An empty reshaped set means the batch moved a cursor with no view observed to change shape; the answer must be "everybody", not "nobody", or the diff believes each client already holds origin-positioned content and sends nothing, leaving them stale permanently.

-

Injected fault: delete the reshaped.is_empty() || disjunct. Fails by name. That injection is why the predicate is a named function rather than a closure — a fail-safe nothing can point at is a fail-safe that gets tidied away.

-

#a_batch_that_changes_the_layout_and_then_saves_ends_clean

-

A regression introduced by the first version of the observed-dirty change and caught in review of PR #70, not by the suite — the workspace was green at 1400 with this broken. observe_reshape ran after the effect loop and re-raised model_state.dirty from the document_config comparison, while Save had cleared it inside the loop. So gotcha 45's stated ordering property — "Save clears it in loop order, so a batch that edits and then saves ends clean" — survived for CellData/WholeWorkbook and was broken for LayoutGeometry.

-

[SetNumberFormat, Save] reported a successful save and left the document dirty: the TUI draws "Saved to …" while Quit's check refuses a later :q, and the browser worker — whose persist trigger is the flag — pays a whole-document OPFS rewrite. Reachable from :format ,.2f . w and from any Binding::Sequence pairing a geometry effect with a save.

-

The fix is position, not logic: the comparison moved inside the loop, taken after each LayoutGeometry effect against the value held after the previous one. Both arms are in the test — [SetCell, Save] never left the loop, so the geometry arm alone could not say whether the rule is general — and each asserts the save really happened (last_batch_aborted, and a status line starting "Saved") before asserting the flag, or a failed save would pass the dirty assertion for the wrong reason.

-

Injected fault: move the comparison back after the loop. Fails naming the layout arm, with 454 others passing.

-

The lesson worth carrying: an observation that is correct about what changed can still be wrong about when it is read. Everything above this entry argues that observing beats declaring; this is the cost of that move, and it lands precisely on the one effect that unsets what the observation sets.

-

#the_published_basis_names_the_records_the_grid_draws_and_no_others

-

improvise-23qd. The set of records a Projection::Basis carries was derived twice — ClientSession::basis_update, the producer, and server_truth, a #[cfg(test)] helper that is the yardstick every fold test measures against. So a change to which records get published moved the thing and its own measure together, and every test stayed green whatever the change was. Not a helper that fabricates an input; a helper that fabricates the expected output.

-

Both now call published_basis. Note what that alone does not buy: the ticket's stated proof ("alter the filter and watch the basis tests fail") is impossible once one function feeds both sides — they still move together. A shared derivation removes the drift and removes the yardstick with it.

-

So the yardstick is spelled out rather than derived: this test names North, South and East by hand for app_with_a_reshapeable_grid's three records over six cells, and asserts that a grid where every cell is an Aggregate publishes none — because an aggregate names no record, so there is nothing to claim a version for.

-

Fault injected: .take(2) on the one surviving filter — the ticket's exact "change which records get published" — fails this test and nothing else in the crate. A second injection, record_version_of(..) + 1, is caught instead by the two end-to-end basis_check tests, which is the split worth knowing: the counters have other guards, the membership had none.

-

The fixture carries its own premise check. The first version assumed a new category lands on Axis::None; it lands on Column, and the assertion said so instead of the test quietly proving nothing.

-

#an_aborted_batch_bumps_layout_version_for_what_it_did_run

-

improvise-2avl, with its negative twin an_aborted_batch_bumps_nothing_for_effects_it_never_ran. PR #70 moved the layout_version bump from "once per LayoutGeometry effect, inside the loop" to "once per batch, after the loop". Gotcha 40 records the in-loop position as load-bearing — an aborted batch must not claim effects it never ran — and the author's argument for why end-of-batch is safe for layout_version specifically (nothing ever clears it, so an end comparison sees only what ran, whereas dirty must stay in the loop because Save unsets it mid-batch) is sound and was written into context/code/command.md. An argument is not a test, and neither arm had one.

-

Both arms are proved independently, which is the part that earns them: bumping unconditionally fails only the second test, and skipping observe_reshape on abort fails only the first. A single injection failing both would not have shown that the two tests see different things.

-

Each fixture asserts app.last_batch_aborted() before asserting anything about the counter, because a batch that did not actually abort would pass both arms while testing neither.

-

#sorting_the_store_in_records_mode_invalidates_the_cursor

-

improvise-ni6k names SortData as its sharpest case: it changes neither the row count nor any label, and every position still names a different record. A guard written as "did the shape change" misses it; pending_edits are keyed by row index (gotcha 1), so row 0 being West and then East is gotcha 18's misdirected-write disease arriving through a different door.

-

view_frame therefore hashes each record's coordinates alongside the axis entries, because records-mode row_items are positional — 0,1,2… whatever order the store is in. Fault injected: deleting those three lines fails this test and nothing else in the workspace.

-

The fixture trap, which is the durable part. PR #78 first shipped without the coords hash, on the stated ground that no fixture could be built where SortData reorders anything. That was wrong, and the reason is worth more than the fix: ToggleRecordsMode pushes effect::SortData as its first effect, so a records-mode fixture built by pressing R has an already-sorted store by construction and cannot demonstrate anything order-sensitive. The store itself was fine all along. So this fixture sets _Index/_Dim directly rather than going through the key path.

-

The test asserts the count and the labels are unchanged before asserting the cursor moved, so it cannot pass by accidentally becoming an ordinary shape change.

-

#paging_with_nothing_pruned_invalidates_no_cursor

-

The negative twin of paging_across_a_prune_that_drops_a_row_invalidates_the_cursor, and the reason view_frame hashes no page selection.

-

improvise-7kvw is real only under prune_empty: a page flip changes which records are visible, not which categories frame them, so with pruning off a grid position keeps naming the same cell. PR #76 ruled that homing the cursor on every [/] regardless — [/] being navigation a user does constantly — is worse than the hole. Hashing the page selection does exactly that.

-

The seductive argument for hashing it anyway is that two pages with identical row labels really are different data. True, and irrelevant: that is record_versions' question, and view_frame answers the cursor's. A basis argument applied to a cursor decision is how this regression got written in the first place.

-

Fault injected: restoring the three page_selections lines fails this test and nothing else. The positive arm keeps passing either way, because with_frozen_records applies prune_empty and so already sees a dropped row in the axis entries — which is why the negative arm is the one that had to exist.

-

#a_write_that_prunes_a_row_out_of_another_clients_view_invalidates_it

-

improvise-mcq4's sting, found by the adversarial review of PR #78. The mcq4 fix narrowed the frame fingerprint from every view to the active one, on the strength of a measurement showing the narrowing cost nothing. It bought its speed partly with correctness: a CellData write that prunes a row out of another client's view became invisible. view_shapes is config-only — it hashes the prune_empty flag, not which rows survive — so last_reshaped_views is empty, the one-view frame covers only the host's, and a client on the other view is never sent ResetCursor while its grid loses a row underneath it. That is gotcha 24's "strictly worse than doing nothing": the diff then believes the bystander is current, so it stays stale permanently.

-

The repair keeps the narrow one-view frame on the LayoutGeometry arm — the held-key path improvise-748f exists for, where other views cannot reframe because view_mut() writes only the active one — and restores the all-views frame on the CellData arm. Measured: 0% on the held-key path, +5.7% on an 18k-cell two-view SetCell, scaling linearly in view count.

-

Writing this test found a second bug, which was masking the first. The precondition failed at first: grid_in built a non-active view with GridLayout::new, which does not apply prune_empty, so a client on another view was projected three rows where its own config gives two. Their grid never pruned, so it never lost a row, so the staleness was unobservable — a bug hiding a bug. It is the third instance of the new-versus-with_frozen_records trap (view_frame was the second), and fixing it broke no other test.

-

Fault injected: removing the all-views arm fails this test, plus sorting_the_store_in_records_mode_invalidates_the_cursor (SortData is CellData) and both cursor-audience tests — four in all.

-

#a_switch_then_a_geometry_effect_compares_within_one_view

-

Found by the same review. frame_basis was taken once at batch open against view_state.active_view; SwitchView is ViewOnly, so it did not re-base, and the next model effect hashed the new view and compared it against the old view's hash. A near-certain mismatch, so a spurious reset_cursor() and a wrong name in last_reframed_views.

-

Today's instances are benign — DrillIntoCell and EnterRecordsMode both emit [CreateView, SwitchView, SetAxis, …] in one batch, but against a view they just created. The harmful case needs a switch to an existing view with a saved cursor plus a model effect in the same batch, which a .-chained : line reaches. Low severity, unsound mechanism.

-

The basis is now keyed by view name and re-bases rather than comparing when the name moves. Fault injected: dropping the was == self.view_name() guard fails this test, with cursor_epoch moving for a batch that reframed nothing.

-

improvise-9c9o moved where the re-base happens, and this test was unchanged by it because it names the property, not the mechanism — the re-base now fires on the switch rather than on the next geometry effect, and a batch that reframes nothing still invalidates nothing.

-

Its fixture carried a line that did nothing at all: views["Second"].clone_into(&mut views["Default"].clone()) clones into a temporary that is immediately dropped. It read as the thing making the two views differ, so had create_view ever started copying the active view's config this test would have gone vacuous with that line standing there looking like it prevented exactly that. It is deleted and replaced by the assertion it was pretending to be: the two views' view_frame hashes really differ.

-

#a_geometry_effect_after_a_mid_batch_switch_still_invalidates_the_cursor

-

The other side of the guard above, and introduced by it. PR #78 round 1 compared frame hashes across a view change, which threw a cursor away spuriously; round 2 added was == self.view_name(), which fixed that and also exempted the first geometry effect after a mid-batch SwitchView from the check altogether — there is no basis for the new view yet, so the code re-based and skipped. Passes on pre-#78 main and on round-1 head; fails at the merged head.

-

Direction is why this is worth more than the bug it replaced. Round 1 kept a cursor it should have thrown away; round 2 threw away nothing it should have kept — which is gotcha 35's expensive side, since a stale cursor has nothing in the stream to correct it where a spurious invalidation costs one projection.

-

The fix is the ticket's: the basis follows active_view, re-based in the loop as soon as the name moves, rather than at the next geometry effect. It did not need SwitchView to stop being ViewOnly and it is not a protocol change — the basis is App's own bookkeeping, and nothing about it is on the wire. The was == self.view_name() guard is gone from the comparison because it is now unreachable: by the time the geometry arm runs, the name and the basis agree by construction.

-

What it changes elsewhere, and why that is fine: DrillIntoCell and EnterRecordsMode both emit [CreateView, SwitchView, SetAxis, SetAxis, …], so they now reset the cursor on the first SetAxis where they used to skip it and reset on the second. Same outcome, one effect earlier. DeleteView is the one LayoutGeometry effect that moves active_view itself; the re-base fires for it too, so the geometry arm compares the new view against itself and resets nothing — which is what the old skip did.

-

The fixture measures the row count the transpose must move rather than spelling it: a first version asserted row_count() == 1 and the real answer is 2, which is the shape of a precondition that would have gone quietly wrong later. It fails on the unfixed tree at the epoch assertion, left: 0 right: 0.

-

#a_frozen_drill_does_not_take_its_opening_frame_from_the_held_layout

-

improvise-mcq4's fix reads the batch's opening frame off App::layout rather than building a second pruned layout — that is where the 4.1×→1.9× came from. It is guarded on drill_state.is_none(), because a drill freezes the record list and the held layout is then not what view_frame would build for that view.

-

The guard was correct, load-bearing, and tested by nothing: reverting it to the unconditional form left all 1439 tests passing. Textbook doctrine(a_careful_comment_is_weak_evidence_that_anything_checks_it), and it was the most carefully argued comment in the PR.

-

The fixture freezes one record where the live query finds three — asserted, so a freeze that did not narrow could not pass — then runs a SetNumberFormat batch, which reframes nothing. Without the guard the opening basis is the frozen layout and the in-loop hash is the live one, so they differ and the cursor is reset for a batch that changed no row. Fault injected: replacing self.drill_state.is_none() with true fails this test and nothing else in the workspace.

-

#a_clients_grid_does_not_depend_on_which_view_its_host_is_on

-

improvise-t6db. The one-line grid_in fix landed in PR #78 with no test of its own; this is the general property the reviewer specced — host-view independence: grid_in(app_on_A, "B", vp) == grid_in(app_on_B, "B", vp) for every ordered pair of views. It needs no second derivation of what a view's config means, which is why it is a property and not a second copy of GridLayout.

-

The trap is the fixture, not the assertion. The reviewer's first version made the two views differ by turning prune_empty off in the second — plenty of differing config, and vacuous: with nothing to prune, GridLayout::new and GridLayout::with_frozen_records agree and the property passed on the buggy tree. So app_with_three_views_and_an_empty_row gives Service no cell at all and leaves all three views pruning, and the test asserts every view really is two rows before comparing anything — a fixture that stopped pruning would fail there rather than passing quietly.

-

The property is stated with drill_state.is_none() asserted, because a frozen drill snapshot is the one thing grid_in cannot reproduce for a view the host is not on (with_frozen_records(.., None) on the elsewhere path).

-

Fault injected 2026-09-21: restoring GridLayout::new in grid_in — the pre-#78 line — fails this test at the assert_eq!, not at the precondition. It also fails a_write_that_prunes_a_row_out_of_another_clients_view_invalidates_it and a_hosts_none_cats_leak_into_another_views_formula_columns: three tests, not the one an earlier draft of this entry claimed. The #78 fix has more coverage than it was credited with.

-

The none_cats asymmetry is REAL, and the first draft of this entry reported it as unobserved from a fixture that lacked the condition. That correction is worth more than the test. The claim was that the fixture put Product on None in Default and on Column in Second; it did not — add_category defaults a new category to Column and nothing moved it, so all three views had none_cats {_Index, _Dim}, the stated input to the hazard was absent, and Default and Second were indistinguishable to grid_in apart from a number_format that is itself a no-op. An unprobed path reported as a negative result, which is doctrine(an_injection_that_does_not_fail_names_an_untested_path) applied to a setup, and the expensive kind: it stops the next person looking.

-

Setting Product to Axis::None explicitly made the property fail on the first run, and not on the value of a cell — Second lost both Total columns entirely when the host stood on Default. Filed as improvise-ac2z, pinned by the characterization test below, and the property now skips pairs whose none_cats differ. That skip is a bug being worked around, not a carve-out; it asserts compared > 0 so a future fixture cannot skip everything and pass.

-

#a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths

-

improvise-jmjz. project::grid_of evaluated the whole grid roughly three times per frame — compute_col_widths walked every cell for its text and then every cell again for the column totals it must size, and grid_of then evaluated the window a third time. ui/viewport.rs::grid_body now evaluates each cell once and derives the widths, the totals and the window from that value.

-

That fusion creates a hazard the three separate passes did not have: one walk now serves three consumers with different scopes. Two faults follow, not one — a window that leaks into the totals makes a column total the sum of the visible rows, and a window that leaks into the widths sizes a column by what happens to be on screen. Neither is visible from the output: the numbers simply get smaller, consistently.

-

The fixture took three attempts, and what each one was blind to is the entry.

-

First: rows of 1, 20, 300000 under a measure named Revenue, asserting col_widths[0] > "300021".len(). It could not see a windowed width at all — Revenue is 7 characters against a 6-character value, so the assertion passed on the header alone. Worse, its other half was structurally incapable: comparing grid_body's widths against compute_col_widths cannot test windowing, because compute_col_widths is grid_body with an empty window. Windowing degrades both sides identically, both collapsing to the MIN_COL_WIDTH clamp. The same function twice is not a check, and it reads exactly like one. Measured by the PR #92 review: if drawn { widen(..) } left 1476 passing, 0 failing.

-

Second, the reviewer's: 1, 888888888, -888888888 with a one-character measure name, so the total cancels to 1.0 and neither it nor the header can supply the width. This fixed the width half and broke the other: the windowed row is 1.0 and the cancelled total is also 1.0, so the totals assertion went blind to the row-window fault, which the widths assertion then happened to catch. Two assertions, one no longer discriminating, and nothing saying which.

-

Third, and current: the same values with the large ones first, so the window holds 888888888 while the total is 1.0. Each injection now fails on the assertion that names it:

-
    -
  • for ri in rows.clone() — the whole walk windowed → totals [888888888.0] against [1.0]
  • -
  • if drawn { widen(..) } — widths only → widths [10] against [11]
  • -
-

10 rather than 5 is a smaller gap than the reviewer's fixture gave and a better witness: the missing character is the minus sign, which only -888888888 carries and the window does not contain. The test also asserts the window really is narrower than the grid, so a fixture that stopped windowing would fail at the precondition.

-

What this test does not do is prove the speedup, and no test can — the outputs are identical by construction, which is why the whole suite passed unchanged across the change. The evidence is the benchmark, before and after, in context/code/command.md: about 3x, the number of passes removed.

-

#a_formulas_columns_survive_a_host_that_aggregates_differently

-

improvise-ac2z, fixed. This replaces the characterization test a_hosts_none_cats_leak_into_another_views_formula_columns, whose message said that failing meant the bug was fixed; it did, and it is gone.

-

App::rebuild_layout calls recompute_formulas(&none_cats) for the host's view, populating Model::formula_cache. project::grid_in on the elsewhere path built the other view's GridLayout against that same cache, which is keyed by the coordinates the recompute used: with Product on None in the host's view the Total cells are keyed without a Product coordinate, a view with Product on Column asks for (Region, Product, _Measure=Total), finds nothing, and — prune_empty being on — drops both Total columns. Not a wrong number: absent columns.

-

host on Second, projecting Second 6 columns, Total present host on Default, projecting Second 4 columns, no Total at all

-

The fix is grid_in::rederived_for: where the target view aggregates over different categories than the host's, it clones the model and recomputes the formulas for that view's none_cats. Three conditions gate the clone — another view, different none_cats, and at least one formula — so the common case pays nothing and a formula-free document pays nothing ever.

-

This is an interim, and the cost is the reason. A clone of the whole model plus a fixed-point recompute, per projection, on exactly the path that is broken today. The ticket's first candidate — keying formula_cache by none_cats so one Model can hold more than one view's derived values — is the durable answer and is filed separately; it is an improvise-core change and did not belong in this wave's ground. What is bought meanwhile is correctness on a path that was silently wrong, doctrine(the_elsewhere_path_is_only_as_correct_as_the_inputs_it_can_rebuild).

-

Fault injected: rederived_for's condition forced to false. Two tests fail — this one, and a_clients_grid_does_not_depend_on_which_view_its_host_is_on, whose none_cats carve-out was removed at the same time and which is the general property this one is an instance of. The test asserts its two preconditions first — that Second on its own really draws the formula's columns, and that the two views really do aggregate over different categories — so a fixture that lost either fails rather than passing on two empties.

-

#a_view_named_command_lands_on_the_view_it_names

-

improvise-lltt, the oldest open P1 here, opened by improvise-jytm making ViewState::active_view authoritative per client.

-

Nine ModelCommand variants configure a view — SetAxis, CycleAxis, TransposeAxes, SetPageSelection, HideItem, ShowItem, ToggleGroup, TogglePruneEmpty, SetNumberFormat — and each named its view implicitly: model_effects built an effect that wrote App::view_mut(), which is the host's slice. A client that had switched to Second therefore moved Default: its own grid did not change, and every other client's did.

-

The fix is the protocol change the ticket asks for. Each of the nine carries a view: String, model_effects maps it to ViewTarget::Named, and the nine effects carry a ViewTarget rather than reading an ambient one. There is no operandless form left: App::view_mut() is deleted, replaced by view_mut_of(&ViewTarget), so a future geometry effect has to say which view it writes. The host's own key path passes ViewTarget::Active, which is right because the TUI is the host and has no session to ask.

-

Fault injected: model_effects' SetAxis arm rebuilt with ViewTarget::Active (the pre-fix behaviour, with view bound and discarded so it still compiles). The test fails on the asker's own view never moved — Second's Channel stayed on Row while Default's moved to Page.

-

The fixture must seat the asker somewhere the host is not, and the two views must disagree about the category being moved: Channel is Axis::None in Default and Axis::Row in Second, and the command asks for Axis::Page, so both assertions separate the fixed code from the broken code in both directions.

-

#a_reframe_of_a_view_the_host_is_not_on_invalidates_its_clients

-

improvise-lltt's second half, and the part the ticket did not predict. Naming the view made a reframe of a view the host is not on reachable for the first time, and Sessions::adopt_cursor_epoch could not see one: it returned early unless App::cursor_epoch had moved, and the epoch is bumped only by reset_cursor, which apply_effects calls only for the host's own view. So the clients on the reframed view were reset by nobody and told nothing — gotcha 24's strictly-worse-than-nothing state, permanently stale rather than stale until the next scroll.

-

App::last_reframed_elsewhere is the second trigger. Its opening frames are taken lazily inside App::view_mut_of, which is the one door such a write goes through, so the held-key path still hashes exactly one view: the wide-observation cost improvise-mcq4 declined is not paid.

-

Fault injected: the gate restored to if !host_moved { return Vec::new(); }. The test fails on a client on the reframed view was never told.

-

Two negative arms, and they are not decoration. A third client left on Default must keep its offset, or "invalidate everyone" passes; and paging_with_nothing_pruned_invalidates_no_cursor is what forbids relaying against last_reframed_views, which is a union with the reshaped set and so fires for a page selection that pruned nothing. That test failed during development and is why the elsewhere set is precise.

-

#a_command_naming_a_view_the_document_has_not_got_writes_nothing

-

App::resolve_view falls back to the document's default for a name the document has not got, which is right for reading — a slice naming a deleted view must not panic in rebuild_layout — and wrong for writing, where it would move a view nobody asked about. view_mut_of therefore looks the name up directly and returns None.

-

Fault injected: view_mut_of's Named arm routed through self.resolve_view(name).0. The test fails on a name the document has not got fell back onto a view that has one.

-

The window this closes is narrow — Sessions::adopt_deleted_views already moves a mirror off a deleted view and tells it — but it is a race, not an impossibility, and the silent alternative is a write to the wrong document state. Not closed here: the client hears nothing. A Rejection variant for it is filed rather than smuggled in, since Rejection is wire vocabulary.

-

#the_diff_tells_a_client_the_grid_became_a_records_grid

-

The producer half of GridCache::records (improvise-e5pf). grid_of setting the field is exercised by leaving_a_panel_in_records_mode_lands_where_the_host_lands; the diff is a separate arm, and a client that connects before records mode is entered learns only through it.

-

Both preconditions are asserted — the fixture starts on an ordinary grid, and R really reaches a records one — so the comparison is between two answers that had something to disagree about.

-

Fault injected: the records arm deleted from grid_updates. The test fails on the client is never told. The negative arm (an unchanged flag is not resent) is what stops "always push it" from passing.

-

#a_batch_that_leaves_records_settles_out_of_records_normal

-

improvise-4dpj, gap 1. settle_normal_mode matches Normal | RecordsNormal, and replacing that with a one-way AppMode::Normal left the entire workspace suite passing. The existing crossing test, a_batch_that_leaves_records_and_then_sets_normal_lands_in_normal, drives the layout both ways, but both of its branches end in enter-mode normal, so the mode handed to the settle is AppMode::Normal in both directions. It discriminates "settles at all" and "settles against the ending layout", and not "settles from either mode".

-

This drives the production repro: from a records grid, apply_effects([SetAxis { "_Index", Axis::None }]) leaves layout.is_records_mode() false with the mode still RecordsNormal, so the RecordsNormal arm is live and not defensive. Both the records-ness of the grid and the mode before the batch are asserted as preconditions, so a fixture that stopped entering records mode fails loudly rather than proving nothing.

-

Injected fault: if matches!(self.view_state.mode, AppMode::Normal). Fails by name, left: RecordsNormal, right: Normal. It is the only test in the workspace that fails.

-

#switching_views_across_records_ness_settles_against_the_view_switched_to

-

improvise-4dpj, gap 2 — raised by ChatGPT Codex against PR #91 and verified correct on main before being tested. SwitchViewAtCursor emits [SwitchView(target), ChangeMode(Normal)], and this is a different path from the one gap 1's siblings drive: that test toggles records mode explicitly, where a view switch crosses records-ness only because it is auto-detected from the axes (gotcha 1). Nothing in the batch says "records".

-

Driven in both directions, with the crossing asserted before and after so a fixture that stopped crossing fails rather than passes. Injected fault: move settle_normal_mode() back above rebuild_layout() at the end of apply_effects — the exact regression PR #91's round 2 fixed. This test fails by name; so does gap 1's, along with six pre-existing records-mode tests (measured in review; an earlier draft of this entry said five).

-

Scope note, stated because an overclaim is worse than a gap: this drives the effects SwitchViewAtCursor emits, not the view-panel key path that composes them. Reaching the panel needs a view_panel_cursor on a CmdContext built in command/cmd/panel.rs, which is another wave's ground this round. The command's own coverage lives there; what is unexercised is the two joined.

-

#the_layout_geometry_cases_are_every_wire_effect_of_that_kind

-

improvise-26dm. layout_geometry_effects_actually_change_document_config was guarded by assert_eq!(cases.len(), 13), which catches a case being deleted from the list and never one being absent from it — and the two are written identically as an assert_eq over a number. The adversarial review of PR #78 added a fourteenth pub struct declaring LayoutGeometry, pointed at by nothing, and the whole suite stayed green.

-

improvise-5ubs's mechanism cannot reach it: an effect is a struct implementing a trait, so serde has no derived enumeration to diff against. model_effects maps each ModelCommand variant back to the effect it came from (gotcha 38), so the list is diffed against the LayoutGeometry effects reachable through ModelCommand. The two sets are equal today, which is the evidence the substitution lost nothing.

-

The right-hand side is bounded by the derive, and that took a second round. Review of PR #96 injected the deletion of ModelCommand::TogglePruneEmpty from every_model_command(): this was the only test in the workspace that failed, and every_model_command's doc comment claiming the list was compiler-forced was wrong — variant_name's exhaustive match forces an arm there, not an entry in the list. ChatGPT Codex raised the same thing on the PR. the_enumeration_names_every_variant_the_derive_declares closes it, so the chain is derive → enumeration → model_effects → case list with every link checked. The assertion here still names both directions, because a short right-hand side reads as a spurious case on the left and invites deleting it — degrading both sides together.

-

Injected fault: drop TogglePruneEmpty from layout_geometry_cases(). The old count said 12 != 13; this says right: [… "TogglePruneEmpty" …] and names the effect.

-

Residual, stated rather than closed: this is total over the wire and not over the effects. An effect declaring LayoutGeometry that no ModelCommand names is still invisible — which is precisely PR #78's probe. Closing that needs an enumeration of effects the compiler maintains, and none exists without a proc macro or inventory-style registration.

-

#the_enumeration_names_every_variant_the_derive_declares

-

The bound the entry above rests on. Injection is Codex's own, run end to end: a ModelCommand::ProbeGeometry whose effect declares LayoutGeometry, with the model_effects and variant_name arms the compiler forces, omitted from both lists. Before this test, the whole workspace stayed green. After it, the failure is staged and each stage names the probe — first every_model_command() never constructs ["ProbeGeometry"], then, once the enumeration is repaired and only the case list is short, the_layout_geometry_cases_are_every_wire_effect_of_that_kind with ProbeGeometry on the right. That staging is the point: the two tests answer different questions and the injection separates them.

-

The name reader is shared, not copied: reduce.rs's effect_type_name and its own the_effect_name_reader_reads_all_three_debug_shapes are what this leans on, because a second reader here would be a derivation done twice and could drift while one copy was repaired. It reads the leading identifier off the derived Debug, Effect being a trait object that type_name cannot reach.

-
-
- - diff --git a/site/docs/test-evidence/core.html b/site/docs/test-evidence/core.html deleted file mode 100644 index 6d219136..00000000 --- a/site/docs/test-evidence/core.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - -improvise-core — improvise docs - - - -
- -
-

Source: context/test-evidence/core.md

On this page

Test evidence — improvise-core

-

What broke, what fault was injected, and why each test can fail. Keyed by exact test name (design-principles §7). A renamed test orphans its entry; scripts/check-comment-runs.py --check-evidence catches a heading naming a test that does not exist.

-

Only tests whose narrative is longer than the code appear here. Most tests need none.

-
-

#model/types.rs

-

#a_hidden_dimension_list_reduces_to_one_of_two_states

-

improvise-5jm5. evaluate_aggregated took none_cats: &[String] and consulted it once, for emptiness — so two lists with different contents and the same emptiness gave the same answer, while the name promised a filter. Nothing misbehaved; the signature described something the body never did.

-

This test is the smaller half of the guard and says so. The real guard is the signature: the defect the ticket describes is no longer constructible, exactly as Checked's constructor is the guard for the basis check rather than a test that can fail. What this test does add is that Aggregation::of is the only place the list is read, and that a list of two hidden dimensions is not somehow a third state — the reduction is emptiness, and the enum is not a count.

-

#apply_agg_over_nothing_is_absent_for_every_function

-

An empty cell must read as absent, not as zero — otherwise every blank intersection in a pivot renders 0 and a sparse model looks fully populated. Fails if apply_agg ever gains a Sum => 0.0 identity arm for the empty case. eval_agg deliberately has the opposite rule; see the next entry.

-

#eval_agg_over_no_rows_is_zero_only_where_an_identity_exists

-

A formula aggregate over no rows is 0 for SUM/COUNT, which have an identity, and an error for AVG/MIN/MAX, which do not. This is deliberately not apply_agg's rule, and the two are separate functions precisely so the difference is visible. improvise-kdp named both after they had been duplicated inline in two evaluators; the test pins the contract directly rather than only through formula eval.

-

#find_item_category_falls_back_to_formula_targets

-

add_formula never inserts its target into _Measure (the item is implied by the formula definition). So find_item_category must fall back to the formula list, or Expr::Ref("Profit") fails to resolve and every cell of a chained formula errors ref:Profit (improvise-nrs). The test asserts the setup — that the target really is not a stored item — before asserting the lookup, so it cannot pass by the item having been added after all.

-

#stored_virtual_categories_match_their_names

-

CategoryKind::of_name is used all over the codebase in place of looking the Category up. That substitution is only sound because (a) a fresh Model really does store each reserved name under its virtual kind and (b) add_category hands back the existing virtual category rather than shadowing a reserved name with a Regular one. The second half is the arm that would break silently: a shadowed _Measure would still answer of_name correctly while the stored category had the wrong kind (improvise-2lh).

-

#aggregating_one_text_value_yields_that_text

-

The head of the improvise-l4qx family. aggregate_cell_values used to be filter_map(as_f64), which dropped text. _Index/_Dim sit on Axis::None in every default view, so none_cats is non-empty almost always and the aggregating path was almost always taken — making every text cell in every document render blank. .improv carries pipe-quoted text as a first-class value, so a user could type a text value, save, load, and never see it again.

-

#aggregating_differing_text_values_is_an_explicit_error

-

Two different words have no reduction. The user is told mixed rather than shown one of them arbitrarily, or a blank.

-

#aggregating_text_among_numbers_is_an_explicit_error

-

The case that produced a plausible number before: the text was dropped and the rest summed, so the cell read as authoritative while under-reporting. A blank would have been noticed; a wrong total is not.

-

#a_lone_number_still_counts_as_one_rather_than_passing_through

-

The regression guard for the hot path, and the test that pins the branch order in aggregate_cell_values. The all-numeric branch is checked first, which is the entire difference between this rule and "a single value passes through". Count is the only function that can tell them apart — every other function is the identity on a one-element set — so this test sets measure_agg["Status"] = Count and requires 1, not 7. Reordering the two branches passes every other test in the family.

-

#aggregating_nothing_is_still_absent

-

The text rule must not turn apply_agg's emptiness rule into an error or a zero. Fails if aggregate_cell_values answers on an empty slice at all.

-

#where_filter_absent_category_does_not_apply_formula

-

Profit = 42 WHERE Region = "East" evaluated against a key with no Region coordinate must not apply the formula: the condition cannot be satisfied, so the raw cell value (absent) is the answer. The bug was an if let Some(item_val) that failed to bind when the category was absent and fell through, applying the formula unconditionally — so a grand-total cell showed the filtered formula's value.

-

#sum_inner_expression_constrains_which_cells_are_summed

-

SUM(Revenue) ignored its inner expression and summed every numeric cell matching the partial key. With Revenue=100 and Cost=50 both stored for Region=East, Total = SUM(Revenue) returned 150. The fixture needs two measures on one coordinate or the bug is invisible.

-

#add_formula_same_target_name_different_category_both_coexist

-

add_formula deduplicated by target name alone, ignoring target_category, so Profit in _Measure and Profit in KPI could not coexist — the second silently replaced the first.

-

#add_formula_same_target_name_different_category_evaluates_independently

-

The consequence of the same bug seen through evaluation rather than through formulas.len(): the dropped formula's cell evaluated to absent. Worth keeping beside the count assertion because a fix that deduplicates correctly but dispatches on name alone would pass the first and fail this one.

-

#remove_formula_only_removes_specified_target_category

-

The mirror image: remove_formula matched by target name alone, so removing Profit from _Measure also destroyed the Profit formula in KPI.

-

#near_zero_divisor_consistent_with_equality_epsilon

-

improvise-0bf. =/!= compare with FLOAT_EQ_EPSILON = 1e-10, but the division-by-zero guard checked rv == 0.0 exactly. With X = 1e-11 the comparison X = 0 was true and 100 / X happily returned 1e13 — incoherent semantics for IF(X = 0, a, y / X), whose else branch divides by a value its own condition called zero. Any value the equality operator treats as zero must also trigger div/0. This exercises the direct eval path (eval_expr inside eval_formula_depth).

-

#near_zero_divisor_consistent_in_cached_eval_path

-

The same bug in the fixed-point/cached path (eval_expr_cached inside eval_formula_with_cache), whose division guard also used exact == 0.0. Two tests because there are two evaluators and a rule stated in one and not the other is a live bug — which is exactly how this one shipped.

-

#recompute_formulas_multi_stem_values_stable

-

improvise-6os replaced an O(n²) Vec::contains dedupe of coordinate stems with an IndexSet — a pure performance change. The test pins the observable contract instead of the mechanism: a multi-stem model (chained formulas, several measures per stem) produces the stated values, and a second recompute_formulas call reproduces the exact same cache, so a dedupe that lost or duplicated stems would show up as a changed value or a non-idempotent cache.

-

#formula_chain_preserves_full_precision

-

Display rounds; eval must not. Tax = Price * 0.075 on Price = 10 is exactly 0.75, which format_f64(_, true, 0) displays as "1". The test asserts both: that the display really does round (so the fixture is discriminating) and that Total = Price + Tax is 10.75 and not 11. Without the first assertion the test would pass on a fixture where rounding changes nothing.

-

#model/cell.rs

-

#matching_cells_yields_the_primary_insertion_order

-

improvise-shyt. matching_cells walked the secondary index's HashSet, so its order was hash order rather than entry order. DataStore::cells is an IndexMap precisely so records mode can list rows in entry order. The check is against iter_cells — the primary map's own order — and not against a literal, which would only pin whatever the hasher happened to do on this machine today.

-

#the_same_cells_entered_in_opposite_orders_list_in_opposite_orders

-

The half a single-process test cannot reach by calling twice: two stores holding the same logical cells, built by opposite insertion histories. Each must list its own history, so one result is the reverse of the other. Under the index walk the two orders were unrelated to both histories and to each other, because RandomState keys each fresh HashMap differently. doctrine(an_order_test_needs_two_histories).

-

#matching_values_yields_the_primary_insertion_order

-

The same guarantee for the value-only hot path, which aggregation folds f64 over. f64 addition is not associative, so an unstable order made a SUM reproducible only within one process — drift in the last bits, which is the kind of wrongness nobody reports and nobody can reproduce. Min/Max/Count and the text-unanimity rule are order-free and were never affected.

-

#an_empty_partial_matches_every_cell_from_either_door

-

improvise-hkt6. matching_positions indexed sets[0] and panicked on an empty partial. Both public callers guard, so it was unreachable — but a guard one level away from the indexing is how this class of bug returns, so the input the guards prevent is fed in directly here.

-

It asserts the answer, not merely the absence of a panic: an empty partial constrains nothing, so every cell matches, and that must be the same answer the callers' fast paths give. Returning vec![] would also have been total and would have made deleting a fast path silently flip "all" to "none".

-

#live_records_come_back_in_the_stores_own_insertion_order

-

improvise-2yc2. live_records walked the records: HashMap, so it yielded a different order in every process and in every instance — the improvise-shyt defect, caught here before a caller could inherit it. The order decided on is the store's own: a record sits where its first cell sits in the primary IndexMap. The fixture interleaves the measures deliberately — East's second measure arrives after West, and must not move East behind it.

-

#the_same_records_entered_in_opposite_orders_list_in_opposite_orders

-

doctrine(an_order_test_needs_two_histories) applied to records: one history cannot tell "ordered" from "hashed that way today, on this machine".

-

#a_recreated_record_takes_its_new_place_not_its_old_one

-

The claim live_records makes about why it derives the order from cells rather than memoising a position on RecordEntry — and which nothing else could see (Copilot, PR 20). A memoised position passes all three order tests above and still puts a re-created record back at its original place while its cells sit at the end of the store. The entry survives the deletion on purpose (it is a tombstone, below), which is exactly what makes the memoised position available to be wrong.

-

#deleting_and_recreating_a_record_does_not_reset_its_version

-

The tombstone earns its place here. If RecordEntry were dropped when the last cell went, a delete-and-recreate would reset the counter to zero — and a client holding version 0 against the old record would be told its basis is current and its write applied. That is precisely the silent accept ADR 001's basis check exists to prevent.

-

#a_surrogate_for_a_removed_record_fails_to_resolve

-

The first arm of staleness: a surrogate for a record whose cells are all gone must fail to resolve rather than resolve to some other record. The fixture holds two records, so a bug returning "any record" is visible; with one, the assertion would pass by naming the only record there is.

-

#a_second_measure_on_a_record_resolves_to_the_same_record

-

The records-mode unit seen from the index side: two measures on one coordinate tuple are one record, so they share one entry. A cell-level unit would be violated by one legal edit (renaming a category coordinate moves every measure cell in the row).

-

#a_live_records_surrogate_is_the_one_that_resolves_it

-

The pair live_records yields is not two facts that could drift apart: the surrogate is the record's own, so it is the one that resolves it back.

-

#a_deserialized_store_can_resolve_its_records

-

The record index is derived state and is #[serde(skip)]-equivalent — it must come back with the data, rebuilt through set, rather than being written alongside it.

-

#model/record.rs

-

#a_records_surrogate_is_pinned_to_a_literal

-

The surrogate must mean the same thing in the process that minted it and in the one that restarts under it, so it is pinned to a literal rather than to "whatever this build computes". A failure here is not a bug in the test: it means the hash, the key or the byte serialization moved, and every surrogate any client holds is now a name for something else. That is a wire-format break.

-

#the_two_kinds_do_not_share_a_hash

-

A record and an aggregate may be built from the same coordinates — an aggregate over a single record is the degenerate case. A client holding an aggregate id must not be able to spend it as a record id by accident, which is what the domain tag byte is for.

-

#coordinates_that_concatenate_alike_do_not_hash_alike

-

The length prefixes in hash_coords earn their place: without them ("Ab","c") and ("A","bc") serialize to the same bytes, and a collision here is a silent write to the wrong record.

-

#renaming_either_half_of_a_coordinate_changes_the_hash

-

Two categories, not one: a one-coordinate fixture cannot tell "the item moved" from "the category moved" — the same blind spot as the one-category fixture that could not distinguish TransposeAxes from CycleAxis.

-

#a_surrogate_round_trips_through_json_as_a_string

-

serde_json writes a u128 as a bare number and a JavaScript host parses every JSON number as f64, so anything above 2^53 comes back changed — resolving to a different record or to none. The test asserts the wire shape (a quoted 32-hex string, 34 bytes with quotes), not merely that a round trip succeeds, because a numeric encoding round-trips fine inside Rust.

-

#construction_order_does_not_reach_the_hash

-

CellKey is canonically sorted and without preserves that order, so "the normalized record" is a property the type guarantees rather than a convention to maintain.

-

#view/types.rs

-

#a_pivot_view_still_seats_categories_row_then_column_then_page

-

improvise-2zq, the negative arm, and the load-bearing one. The fix makes a records view seat a new category on Axis::None; without this test, "always seat on None" would pass every assertion about records mode while quietly deleting the auto-assignment on_category_added exists for.

-

Probing confirmed the asymmetry: seating every category on None fails 12 tests, while removing the virtual bump failed none at all — which is how the bump came to be wrong in two directions with nothing noticing.

-

#a_records_view_seats_a_new_category_on_none_and_stays_a_records_view

-

on_category_added used to bump a virtual off Row or Column to make room. With _Index on Row and _Dim on Column that is records mode, so naming a category with N while in records mode silently ejected the user from the grid they were standing in and returned them to a pivot.

-

#a_measure_on_column_is_not_bumped_off_it

-

The second thing the bump broke, and the reason the fix is about virtuals in general rather than about records mode: _Measure on Column is an ordinary configuration — it is what web/demo.improv ships — and adding a second regular category kicked it off the axis, making every measure vanish from the grid. The fixture is deliberately not a records layout, so it exercises the pivot path rather than the early return.

-

#cycle_axis_unknown_category_is_noop

-

improvise-t8s: cycle_axis panicked through axis_of's expect when given a category not registered with the view, while set_axis silently no-ops on the same input. It now goes through try_axis_of.

-

#tileable_cats_omits_filter_but_keeps_every_other_axis

-

improvise-dz8: a coordinate the drill fixed must not appear in tile select — the user cannot move it, so offering it offers an action that cannot be taken. The test flips the category back to Page afterwards, so it distinguishes "omits Filter" from "omits this particular category".

-

#filtering_cats_covers_page_and_filter_but_not_the_rest

-

Filter constrains the data exactly as Page does; only Page is navigable. The fixture puts a category on each of Page, Filter, Row and None so the result separates the two questions rather than merely counting.

-

#view/layout.rs

-

#prune_axis_items_indexes_data_positions_not_visual_ones

-

prune_axis_items asks keep about data indices — the positions a row/col argument means — so a group header between two data items must not shift the numbering the predicate sees. The fixture places the header at visual index 1 and prunes data index 1: if the predicate saw visual indices, "A" would be the one dropped. improvise-n3wt also removed the &[bool] + debug_assert pair, whose length could disagree with the axis in release builds only.

-

#a_text_cell_renders_in_a_default_view

-

The user-visible face of improvise-l4qx, asserted at the layout rather than at the aggregation helper. The test asserts its own premise first — !layout.none_cats.is_empty() — because if a default view ever stopped putting _Index/_Dim on Axis::None the test would pass without exercising the bug at all.

-

#records_mode_gives_each_measure_its_own_value_column

-

Pins the whole column vocabulary in order: [Region, +cat, Revenue, Cost, +] — the regular categories, the new-category column (improvise-2zq, sitting where the column it creates will appear), one column per raw measure, then the new-measure column (improvise-c5v). It checks both the labels and the RecordCol variants, so a column drawn with the right label but resolved to the wrong kind fails.

-

#records_mode_initial_layout_is_sorted

-

On entry into records mode the store is sorted by CellKey, so rows appear in a deterministic order regardless of insertion order. The fixture inserts East after North, the opposite of alphabetical, so "did nothing" fails.

-

#records_mode_new_record_appends_at_bottom

-

The complement: once the layout exists, a new record appends rather than re-sorting into the middle. The new record's coordinate (AAA) sorts before every existing one, so a re-sort would move it to the top and move the row the user was looking at.

-

#records_display_out_of_bounds_col_returns_none

-

improvise-byz: records_display for an out-of-bounds column called col_label(col), which returns ""; "" was then looked up as a category name in the record, never found, and the function returned Some("") — masking view/data mismatches as blank cells. It must return None, symmetric with cell_key.

-

#view/axis.rs

-

#the_label_vocabularies_are_unchanged

-

improvise-rml, characterization: these strings were scattered across four render/command sites before being pulled onto Axis. Pinned verbatim so the move could not quietly restyle the UI.

-

#filter_is_outside_the_rotation

-

improvise-dz8: Filter names a coordinate the drill fixed, so it is outside the Row→Column→Page→None rotation entirely. Both directions are asserted — cycling a Filter must not hand the user a navigable axis, and no navigable axis may rotate into Filter, or tile select could hand one out.

-

#next_is_a_four_cycle_over_the_navigable_axes

-

Applying next four times is the identity from every starting point and no navigable axis is a fixed point, so no variant is stranded or skipped. Together with the entry above this pins the rotation without enumerating it twice.

-

#every_axis_gets_its_own_color

-

Distinct axes must be distinguishable at a glance; color() is the only reason AxisColor exists, so a collision defeats it.

-

#a_routing_formula_over_a_group_sums_the_members_answers

-

improvise-4zl7, and the hand-computed half of its evidence. Every number in route_then_aggregate_tests is written out rather than obtained from a second call into the evaluator, because the bug being guarded is the evaluator answering consistently and wrongly.

-

The fixture is four records — Cost of 10 and 100 in North, 20 and 200 in South — and the formula is Pred = SUM(IF(Cost < 50, 1, 10)). Cost straddles the threshold inside each region, which is the whole design of the fixture:

-
cellroute-then-aggregateaggregate-then-route
one record (Cost 10)11
North (Tag away)1 + 10 = 11110 < 50 → 10
everything1+10+1+10 = 22330 < 50 → 10
-

A fixture whose records fall on the same side of the split would give the same answer either way at the record level and differ only by a factor of the record count, which is the kind of agreement that reads as correctness.

-

Two faults were injected and both were watched to fail. Restoring the old eval_agg — sum the matching raw values and ignore the inner expression — fails 7 of the 8 tests with 330 for 22, 110 for 11 and 10 for 1; the one survivor is is_is_one_when_the_cells_own_coordinate_matches_and_zero_otherwise, which has no SUM and so never reaches the fold. Making eval_is return 0.0 unconditionally fails exactly the 3 coordinate tests, with 400 for 214 and 200 for 14. The two faults failing disjoint-but-overlapping sets is itself the evidence that the tests separate the fold from the predicate.

-

#a_coordinate_split_survives_aggregating_over_that_very_category

-

The case the ruling exists for, and the one the ticket's own text called the product question. With Region aggregated away the cell has no Region coordinate of its own, so a formula that read the cell's coordinate would see nothing and take Expr::Is's unwrap_or(false) branch — answering 100 once. The fold hands the expression a record key, so each of the four records answers for its own coordinate: 7 + 7 + 100 + 100 = 214.

-

#a_member_whose_input_is_missing_errors_rather_than_being_dropped

-

The asymmetry between the Expr::Ref fast path and the fold, asserted rather than left to be discovered. SUM(Revenue) skips a record with no Revenue cell; SUM(<expr>) cannot, because the expression is what is being asked and an expression that will not evaluate is an error. The fixture clears one record's Cost and writes a different measure on the same record, so the record still exists and still has a member cell to be found by — otherwise the record would simply vanish from matching_cells and the test would prove nothing.

-

#feature_roles_line_up_with_the_names_preprocess_emitted

-

Lives in gp-tree, recorded here because the rule it guards is improvise-4zl7's. feature_roles is the one statement of preprocess's expansion order and encode_row drives off it, so a drift is silent in both directions: the emitter would spell the wrong feature and a re-encoded row would land in the wrong column. The assertion rebuilds each feature's name from its role and compares it with the name preprocess emitted, which is the only thing that ties the two walks together.

-

The last four assertions exist because the loop is vacuous over a fixture that produces only one kind of feature. The fixture has a numeric column with a missing cell, a categorical column and a date column, so all four roles appear. Fault injected: swapping the Numeric and Missing pushes in feature_roles fails this test and encode_row_replays_the_training_expansion, which is the pair that would have disagreed.

-

#a_formula_targeting_another_category_still_folds_over_its_records

-

Found by self-review, not by the ticket. The fold evaluates the inner expression at each member record's key, and the first version built that key by folding the record's coordinates onto context — which still carries the formula's own target coordinate. For the ordinary _Measure target that is harmless, because Expr::Ref overwrites _Measure on the way in. For a formula targeting any other category it is not: the record key keeps Levy=Tax, so evaluate_depth matches the formula again at that key and the whole cell converges to Error("circular") — a formula quietly self-referencing through its own aggregate.

-

The key is now folded onto partial (context.without(target_category), plus any inline WHERE), which drops the target coordinate by construction rather than by every caller remembering to. Fault injected by restoring the context fold: this test fails with Error("circular") against 11.0 and the other eight pass, which is why it is a separate test and not an extra assertion on one of them.

-

#the_two_evaluators_agree_when_the_fold_reads_a_derived_measure

-

Adversarial review of PR #54, and the sharpest finding of the round: the first version of the fold reintroduced exactly the divergence the ruling exists to remove, through the cached path.

-

eval_expr_cached's Expr::Ref looks the value up in formula_cache at context.with(cat, name). Inside the fold, context is a member-record key carrying the none_cats coordinates, while formula_cache is keyed by stems with none_cats stripped. The lookup could never hit, fell through to aggregate_raw, found no raw cell behind a formula target, and answered Err("ref:…"). So a routing formula reading a derived measure gave Number(11.0) uncached and Error("ref:Margin") cached — and the cached path is the one a pivot grid runs whenever any category sits on Axis::None.

-

The fixture is the four-record one plus Margin = Cost * 2, so the fold's input is a formula rather than raw data. The test asserts the uncached value and that the two agree; asserting only agreement would pass if both broke.

-

The fix makes them agree by construction rather than by this test: eval_expr is hoisted to module level and both evaluators hand it to eval_agg as eval_at. Note the repair that was not taken — teaching the fold to hit the cache by stripping none_cats off the member key returns the group's value to every member, which is aggregate-then-route through a cache lookup, silent. Fault injected by restoring eval_expr_cached as the fold's evaluator: this test fails with Error("ref:Margin") and the other eleven pass.

-

#the_cached_evaluator_has_its_own_working_coordinate_predicate

-

The same review's second finding, and a lesson about where to inject a fault. Expr::Is has an arm in each evaluator. The original evidence injected the fault into the shared eval_is, which both arms call — proving the tests see the predicate, not that they see the cached copy. Replacing only the cached arm with Ok(0.0) left the suite at 1330 passing.

-

The first attempt to close it — a coordinate split run through recompute_formulas + evaluate_aggregated — also stayed green, and that is the interesting part: once the fold routes through eval_expr, an Is inside a SUM never reaches the cached arm at all. The cached arm is now reachable only by a bare Is outside an aggregate, which is what this test uses. Fault injected into that arm alone: it fails, Number(0.0) against Number(1.0).

-

The general shape: an injected fault proves coverage only of the code path it is injected into. Two arms of the same rule need two injections, and if one of them turns out to be unreachable, that is a fact about the design worth writing down.

-

#a_user_category_whose_name_leads_with_an_underscore_is_seated_as_regular

-

improvise-bidq. View::on_category_added's early return read cat_name.starts_with('_') || self.is_records_layout() — the same expression mixing two derivations one operator apart, the left a name prefix and the right CategoryKind. Four lines up, is_records_layout asks the enum.

-

Model::add_category does not validate names, so a user category literally named _Foo was CategoryKind::Regular to records_columns, cell_id and is_empty_model, and "virtual" to these two lines — seated on Axis::None, so it never appeared in a pivot while every other consumer treated it as ordinary. The second site (holds_a_regular) had the same split, which is why the test asserts a second category's seat too: fixing only the first leaves _Foo on Row while Region also takes Row.

-

Decided explicitly, per the ticket: add_category is not made to refuse a leading underscore. The three reserved names are the ones CategoryKind::of_name knows, _Foo is a legal document, and a user-visible restriction is a larger change than making one answer out of two. The fix is that both sites now ask CategoryKind::of_name(..).is_virtual().

-

Fault injected by restoring either starts_with('_'): the test fails, None against Row on the first site and Row against Column on the second.

-

#an_ungrouped_item_after_a_grouped_run_belongs_to_no_group

-

#a_grouped_run_does_not_leak_into_the_next_prefix

-

improvise-gd83, the two arms of one defect. expand_category emitted a GroupHeader on entering a group and no boundary on leaving one, so row_group_for/col_group_for's nearest-preceding walk answered with a group that had already ended.

-

The arms are separate because the two escapes are different and a fix can close one and miss the other. The first is within one expansion — Jan[Q1], Feb[Q1], Sep — closed by emitting AxisEntry::GroupEnd on a Some -> None transition. The second is across expansions: cross_product expands the inner category once per prefix, each call starting with last_group = None, so Food/Bob[Family] is immediately followed by Clothing/Carol, which is ungrouped and inherited Family with no transition anywhere to hook onto. That one needs a GroupEnd at the end of an expansion whose last item was grouped.

-

GroupEnd is deliberately NOT emitted on Some(A) -> Some(B): B's own header already ends A's run, and emitting one there moved data_row_to_visual_skips_headers and data_col_to_visual_skips_headers for no gain. Those two are untouched, which is the evidence the boundary is as narrow as it claims.

-

Fault injected by deleting either emission: the corresponding test fails with Some(("Month", "Q1")) / Some(("Recipient", "Family")) against None, and the_two_front_ends_draw_the_same_column_header fails with it.

-

#a_trailing_conversion_letter_does_not_erase_the_precision

-

improvise-711n. parse_number_format read the precision as fmt[i+1..].parse::<u8>().ok().unwrap_or(0) over everything after the last ., so any trailing conversion letter made the whole suffix unparseable and the format fell back to zero decimals, silently. format: ,.2f — the spelling context/repo-map.md's own .improv example carries, and the printf/Python convention a user reaches for first — displayed integers everywhere.

-

Fails before the fix with left: (true, 0), right: (true, 2).

-

The test also pins the reading against its nearest wrong neighbour. Two implementations both fix ,.2f: take the leading digit run, or trim the trailing non-digits off the tail. They differ only on a tail with digits on both sides of a letter, so ".2f3" is in the assertion. Injecting s.trim_end_matches(|c: char| !c.is_ascii_digit()) in place of leading_digits fails the test at left: (false, 0), right: (false, 2) — so the test sees the rule, not merely the bug.

-

#a_zero_padded_picture_string_is_read_as_printf_not_as_excel

-

The companion to the above, and the reason the fix is not "be generous". A forgiving reader would have to answer .00, where printf says precision zero and an Excel picture says two places. This pins the printf reading — "0.00" is (false, 0) — so that "accept the trailing f" cannot quietly grow into "accept anything and guess". It passes before the fix as well as after: it is there to stop the next change, not to catch this one.

-

#a_precision_too_large_to_name_reads_as_none

-

".300" does not fit u8, so it reads as no precision rather than as 300 decimal places. Unchanged by improvise-711n and pinned so the fallback is a stated rule rather than an artefact of the integer type.

-

#a_format_naming_no_precision_at_all_reads_as_zero

-

",f" has no ., and ".f" has a . with no digits after it. Both are zero decimals. Pinned because leading_digits returns "" for the second, and an empty string reaching parse::<u8>() is the one path in the function with no digits to carry the answer.

-
-
- - diff --git a/site/docs/test-evidence/formula-and-io.html b/site/docs/test-evidence/formula-and-io.html deleted file mode 100644 index bfb4b4d0..00000000 --- a/site/docs/test-evidence/formula-and-io.html +++ /dev/null @@ -1,227 +0,0 @@ - - - - - -improvise-formula and improvise-io — improvise docs - - - -
- -
-

Source: context/test-evidence/formula-and-io.md

On this page

Test evidence — improvise-formula and improvise-io

-

What each of these tests is evidence of: the bug it reproduced, the fault that was injected, and why it can fail. Keyed by exact test name — a rename orphans the entry, and scripts/check-comment-runs.py --check-evidence catches that. The mechanism arguments are in context/code/formula-and-io.md.

-

### is reserved for a name that really is an annotated test. A fixture, strategy or shared harness whose narrative is worth keeping gets a #### subordinate heading that says so. The checker as written accepts any fn, so a helper under ### would pass today and fail the moment that is tightened.

-
-

#improvise-formula/src/parser.rs

-

#parse_sum_with_inline_where_filter

-

Regression: a WHERE inside the aggregate's parens must parse as the aggregate's inline filter, not as a top-level WHERE clause on the formula. The two land in different places on the AST — Expr::Agg(_, _, Some(filter)) against Formula::filter — and the difference is silent at parse time.

-

#hyphen_in_bare_identifier_reference

-

Regression. The hand-rolled tokenizer that preceded the pest grammar did not allow - inside bare identifiers, so a reference to a persistence-legal name like east-coast was silently parsed as east - coast: a subtraction of two unrelated Expr::Refs, which evaluates rather than erroring. The pest grammar mirrors improv.pest's bare_name, which does allow -.

-

#hyphen_in_bare_identifier_inside_aggregate

-

The same fix through the aggregate path, because SUM(east-coast) reaches the identifier rule by a different route.

-

#pipe_quoted_target_is_unquoted_like_a_reference

-

Regression, and a data bug rather than a cosmetic one. The tree walker took the target's raw span, so a pipe-quoted target came back with its delimiters still attached while an Expr::Ref for the very same name came back unquoted. Downstream, Model::add_formula then created a _Measure item literally named |My Target|, which never matched the user's item My Target: the formula wrote into a ghost column and nothing referring to it by name resolved.

-

The test asserts both halves — the stripped target and that the reference spelled identically names the same item — because asserting only the target would pass under a walker that unquoted both wrongly in the same way.

-

Why no existing test caught it: the writer emits Formula::raw, not target, so the file text is identical either way and every round-trip test was blind to it. See context/code/formula-and-io.md § "A formula target is unquoted".

-

#pipe_quoted_target_applies_escapes

-

The escape table applies to the target too, not only to Expr::Ref — the companion that stops the fix above from being "strip the outer pipes".

-

#bare_target_is_unchanged

-

The negative arm: a bare target has nothing to strip. Without it, "always strip the first and last character" would pass the two tests above.

-
-

#improvise-formula/src/quote.rs

-

#unescaping_inverts_quoting

-

The reading half inverts the writing half over every escape both grammars define, plus the \X passthrough neither of them assigns a meaning to. The "42" case is there because pipe_quote is unconditional: a .improv text cell holding 42 must not read back as a number.

-

#a_trailing_lone_backslash_is_kept

-

Before improvise-i2m5 the unescape table existed twice — here (as parser.rs::unquote_pipe) and as improvise-io/src/persistence/quoting.rs::unescape_pipe. The two agreed on every input either grammar's pipe_inner can produce, and differed only on a trailing lone backslash, which pipe_inner cannot emit: io returned it, parser.rs panicked. This pins the surviving answer for that unreachable input, so the surviving function is total.

-

#a_quoted_name_round_trips_through_the_parser

-

The property the whole module exists for: a name quote_name spells parses, and the parser hands back the name that went in — as a target and as a reference, which after the target-unquoting fix are the same rule. The fixture list is the interesting part: keywords (WHERE, SUM), a leading digit (9lives), the escapes, and the empty name.

-

#a_keyword_is_quoted_even_though_it_looks_bare

-

Pins the keyword guard, which is deliberately stricter than the grammar requires and is user-confirmed — do not revert it as over-caution. The argument (PEG rule ordering, and ## Formulas carrying formula text verbatim) is in context/code/formula-and-io.md.

-
-

#improvise-io/src/storage.rs

-

#a_shared_backend_is_a_backend_and_a_clone_is_the_same_one

-

improvise-hmws. A worker must hold the concrete backend — for the four async methods the trait does not carry — and hand the App a Storage. Before the blanket impl Storage for Rc<T>, Rc<OpfsFs> was not a Storage and could not be made one outside this crate (orphan rule), so improvise-worker-server wrote a forwarding newtype, and so would every worker after it.

-

The regression guard is the signature: this does not compile without the impl. What it asserts beyond compiling is the part a newtype could still get wrong — that the clone the App holds and the handle the message loop keeps are one backend and not two.

-

#memory_fs_satisfies_the_storage_contract

-

#physical_fs_satisfies_the_storage_contract

-

Both run contract::storage_contract at a non-empty dir, which is the whole point of dir being a parameter: at Path::new("") a backend that answers list in the wrong path shape passes, because joining is the identity there. OpfsFs did exactly that and the disagreement was invisible until improvise-vhcz (see context/code/formula-and-io.md).

-
-

#improvise-io/src/persistence/mod.rs

-

#The shared harness: load_corrupt_gzip

-

Not a test, and so deliberately not a ### heading — the harness the two gzip-corruption tests below share. It asserts two preconditions before handing the damaged bytes to the loader: that the corruption actually changed the bytes, and that the result still looks like a gzip document by its magic bytes. Without the second, a corruption that also broke the routing would make the loader fail for a different reason and the test would pass while proving nothing.

-

#a_truncated_gzip_document_fails_as_an_unexpected_eof

-

A .gz document cut short — the disk filled, or the process died mid-save. The kind is asserted (io::ErrorKind::UnexpectedEof), not merely that an error occurred, because "an error occurred" would also pass if the loader had stopped decompressing altogether and choked on the compressed bytes as text.

-

#a_gzip_document_with_a_flipped_byte_fails_its_checksum

-

The other way a .gz arrives damaged, and it is a different kind (InvalidInput), which is the reason to test both: one test against one corruption cannot tell "the decoder refuses damaged input" from "the decoder refuses short input". The first is incomplete, the second is wrong — "you lost the tail" versus "you cannot trust any of it".

-

The byte flipped is in the CRC32 field, the first four of the eight-byte gzip trailer, and not in the DEFLATE payload (Copilot, PR #22). A payload byte also yields InvalidInput, but it can do so for malformed compressed data rather than for the mismatch the test is named after, and the name would then be a guess about which. Corrupting the trailer leaves the stream decodable, so the only thing left to fail is the checksum — hence the assertion on the source error's message, since anyhow's to_string shows only the outermost context.

-

#a_valid_gzip_of_invalid_utf8_is_reported_as_not_utf8

-

The third failure, and the one that made the other two worth keeping apart in the code as well as in the tests (Copilot, PR #22). A gzip stream can be perfectly well-formed and still hold bytes that are not UTF-8; read_to_string decodes as it decompresses, so a single Context around it called that document unreadable gzip — a wrong diagnosis of a document that decompressed without complaint.

-

The precondition asserts the payload really is invalid UTF-8; without it the test would also pass on a document that merely failed to decompress, which is the case the other two already own. It also asserts the message does not mention gzip: the gzip stream was fine, and blaming it is the bug.

-

#The fixture: storage_fixture

-

Not a test. A workbook with a category, items, a formula and data — enough that a round-trip exercises every section of the format. Every gzip test above and every storage round-trip below reuses it, so that "the document survived" is a claim about a real document rather than about an empty one.

-

#round_trips_through_memory_storage

-

improvise-6mq: persistence goes through Storage, so a workbook can be saved and loaded with no filesystem at all — no tempfile, no disk. This is the shape the browser backend plugs into.

-

#gzip_round_trips_through_memory_storage

-

The .gz suffix selects gzip regardless of backend: a property of the document name, not of the filesystem.

-

#physical_and_memory_backends_write_identical_bytes

-

Swapping the backend must not change the bytes written. This is what stops a backend from quietly normalising or re-encoding what it is handed.

-
-

#improvise-io/src/persistence/parse.rs

-

#two_sections_naming_one_category_round_trip_to_a_stable_group_order

-

improvise-349m: format_md(parse_md(text)) was not idempotent when a file carried two ## Category: sections with the same name — the two outputs differed only in the order of the > group lines. It looked like a flake because only grammar_prop_tests::generated_file_roundtrips saw it, and only when the generator happened to emit a duplicate header; taking proptest's minimal input and running it deterministically is what turned it into a bug.

-

The fixture is the smallest shape that shows it: a first section defining a group by a > line only, and a second whose item names a different group that also gets a > line. Both groups must have parents, or format_md writes only one > line and the differing internal order is invisible in the text — which is the trap when shrinking this fixture further. Fault injected: reverting ParsedFile::push_category to a plain Vec::push fails it, and the ticket's own minimal input was checked against the fix as well.

-

Stability is only half of what the merge owes, and the first version of this test could see only that half. Adversarial review injected the other fault — Some(_existing) => {}, which drops the duplicate section outright — and the whole improvise-io suite stayed green at 2000 proptest cases while the fixture's - X[G2] and > G2[P2] vanished from the document without a word. Main's behaviour was unstable-but-lossless; a test that compares two printed forms cannot tell that from stable-and-lossy, because both sides of the equality lose the same thing. The three contains assertions run before the equality for exactly that reason, and they fail under the drop.

-

#parse_md_formula_category_suffix_without_space

-

Bug improvise-zgb (b). The formulas walker located the [Category] suffix with raw.rfind(" ["), so a hand-written line with no space before the bracket — - Total = Food + Gas[Type] — was not recognised and the formula was silently retargeted to _Measure. The suffix is now part of the grammar (formula_line) and walked structurally.

-

#parse_md_formula_ending_in_bracketed_pipe_name_keeps_raw

-

Companion to improvise-zgb: a formula ending in a pipe-quoted identifier that contains " [" must not have the quoted name mistaken for a category suffix. It stays a _Measure formula with its raw text intact. Without this arm, "look for the last [" would pass the test above.

-

#unparseable_number_token_yields_error_not_zero

-

Bug improvise-4yc. The Rule::number arm used as_str().parse().unwrap_or(0.0), so any numeric token the grammar admitted but f64::from_str rejected silently became Number(0.0) — a fabricated value in a spreadsheet.

-

No token the current grammar admits actually fails f64 parsing (overflow saturates to inf), so the test exercises parse_number_cell directly, with the forms the grammar could plausibly drift toward admitting. That is the point: the guard exists for the drift, not for today.

-

#malformed_numbers_in_data_never_silently_zero

-

The same bug through the grammar, covering all three routings: --5 fails the number rule entirely and becomes bare_value → Text; 3.14.15 matches the 3.14 prefix, commits, and the rest fails → a whole-file parse error naming the text; 1e999 is admitted and saturates to +inf. None of the three is 0.0.

-

#parse_error_names_line_number_and_contents

-

Bug improvise-6kj: parse failures were wrapped as Parse error: {e}, leaning on pest's raw rendering instead of stating the location in plain words. A user fixing a hand-edited file needs the line number and the offending line.

-

#roundtrip_preserves_an_awkward_category_name

-

improvise-pczv as a property rather than a list. quote_header decides which names the header line cannot carry, and that decision is a small set of clauses — so the property is what earns its place: for any name, save-then-load must give the name back. It runs against populated_model rather than a fresh one because a workbook with no data cannot show a coordinate losing its category, which is the silent half of the bug.

-

#Why populated_model and not a fresh workbook

-

Not a test — the fixture the property above runs against. It already holds categories, items and cells, and the reason is the evidence: a default-constructed workbook makes the round-trip test vacuous for the data-coordinate half, because there is no coordinate to lose its category.

-

#The strategy: awkward_name

-

Not a test — the input strategy the property above draws from. The generated shapes are the whole-line contract's stress points: edge whitespace, embedded line breaks, pipes, and text that looks like another section marker (\n## Data).

-
-

#improvise-io/src/persistence/format.rs

-

#virtual_views_are_not_written

-

improvise-60z. _Records and _Drill are scratch views the app builds on demand. They put _Index/_Dim on axes, and those categories are never written to the file — so persisting the views produces axis lines referring to categories the loader will not find. The test also asserts a real view is written, so "write no views at all" cannot pass it.

-
-

#improvise-io/src/persistence/roundtrip_tests.rs

-

#initial_view_skips_a_virtual_active_view

-

improvise-60z's writing half: saving while in records mode must not leave Initial View: _Records pointing at a view the file no longer contains. It asserts the loaded workbook's active view exists in the loaded workbook, not merely that the string changed.

-

#virtual_views_in_an_existing_file_are_dropped_on_load

-

The reading half: a file written by an older build may already carry the scratch views, and loading one must not reinstate them.

-

#filter_axis_round_trips_with_its_selection

-

improvise-dz8. Axis::Filter arises only inside the _Drill view, which is never written, so this is defensive — but the alternative to supporting it in the format is silently writing a Filter axis as something else.

-

#parse_md_round_trips_formula_category_with_special_chars

-

Bug improvise-zgb (a). rfind(" [") mis-split a suffix whose category name itself contains " [": the writer emitted [Margin [%]] unquoted and the parser then split inside the name, producing garbage raw text and a category called %]. Category names in the suffix are now pipe-quoted on write and parsed via the grammar's name rule.

-

#a_keyword_named_category_is_pipe_quoted

-

improvise-i2m5. .improv now spells names through improvise_formula::quote_name, which pipe-quotes a formula keyword. Before the switch, io's own copy of the rule had no keyword guard and wrote ## Category: SUM, - WHERE and SUM=WHERE = 1 bare — that was the only output difference the whole persistence suite showed.

-

The names round-trip either way (improv.pest accepts pipe_quoted wherever a name goes), so this pins the spelling, not the survival. It also pins the header exception: category_section takes the whole line and never goes through quote_name on either side, so only references are quoted.

-

#a_keyword_named_category_round_trips_everywhere_it_appears

-

The round trip the quoting exists for, at every position a keyword-named category or item reaches the file: the axis line, the page selection, hidden:, the category section and the data coordinates. A guard that covered only some of them would pass a single-position test.

-

#a_formula_targeting_a_keyword_named_measure_round_trips

-

Why the keyword guard is worth its cosmetic cost even though bare keywords parse: a ## Formulas line carries formula text verbatim, so a name written by format_md is later read by parse_formula. The two grammars share one namespace, and quote_name is the one rule spelling names for both.

-

#a_category_name_the_header_line_cannot_carry_round_trips

-

improvise-pczv. A section header was the one name position that never went through a quoting rule, so a name the header line could not carry was written raw into it. The table NAMES_THE_LINE_CANNOT_CARRY is the whole rule, and each entry is a different failure:

-
    -
  • Region\nName — the newline ended the header and the rest became a stray line: the file would not parse at all.
  • -
  • Region\n## Data — worse: the remainder looked like another section marker.
  • -
  • "Region " / " Region" — the reader trims, so the category came back renamed while every data coordinate still named the untrimmed original: silent orphaning.
  • -
  • "" and "|Region|" — would be taken for a quoted name and unquoted.
  • -
-

This is reachable and not theoretical — see a_csv_header_with_an_embedded_newline_survives_a_save.

-

#a_model_and_view_name_the_header_line_cannot_carry_round_trip

-

The same rule at the other three header positions: the model title, the view title, and the Initial View: reference that must still resolve to the view.

-

#an_ordinary_spaced_header_stays_bare

-

The negative arm, and the one that stops quote_header from quietly becoming quote_name. The header carries its name as the whole line — that is the contract — and quoting a name the line carries fine would rewrite the header of every existing document for nothing. It also asserts that a reference to the same name is still quoted, so the two rules stay distinguishable.

-

#pipes_that_do_not_close_the_header_line_are_part_of_the_name

-

The one genuinely ambiguous line, decided by the grammar's &NEWLINE: ## Category: |a| and |b| is a category called |a| and |b|. Without the lookahead the pipe-quoted arm would match a and silently drop the rest of the line.

-

#a_header_written_by_an_older_build_still_loads

-

A header written before improvise-pczv is a bare line and must still load as one — spaces, pipes in the middle and all. The compatibility direction of the same grammar change.

-

#a_csv_header_with_an_embedded_newline_survives_a_save

-

The bug as a user meets it, and the reason improvise-pczv is a corruption bug rather than tidiness. An RFC 4180 CSV may quote a header field containing a newline; nothing on the import path forbids it, so the category carried one — and the .improv file the user then saved could not be reopened. The test asserts the precondition (the import still admits the header verbatim) so it cannot pass by the import having quietly started rejecting it.

-

#an_item_name_the_item_line_cannot_carry_round_trips

-

improvise-odbc. bank-info.improv in the repo root failed to load at - JORDAN MARTINEZ (line 293) with expected pipe_quoted. The ticket named two readings — a stale file, or a writer/reader gap in which improvise emits an item line the grammar rejects — and said to decide by asking whether improvise can write that line today.

-

It cannot. item_list's name is pipe_quoted | bare_name, and bare_name is [A-Za-z_][A-Za-z0-9_-]* with no space; quote_name mirrors it character for character, so every one of those item names is emitted pipe-quoted and read straight back. The file was hand-edited in c48a5cd — the v2025-04-09 migration quoted the Date items and left the Entity ones bare — and never round-tripped. So the reading is "stale file", and the standing finding was that nothing exercised it. This test is what that finding turned into: the item names from the file that motivated the ticket, driven through the writer and back. It asserts both directions — the spaced name is quoted, the bare-legal name stays bare.

-

#a_bare_item_name_with_a_space_is_rejected

-

The other half of the same finding: the exact line the file carries is not something the reader accepts, so the file really was broken rather than the error message being misleading. Asserting the failure keeps the test above honest — without it, "the writer quotes it" would be consistent with the reader also accepting the unquoted form. The trailing precondition (the same line quoted does parse) stops it passing because of some unrelated syntax error.

-
-

#improvise-io/src/persistence/export.rs

-

#export_csv_quotes_commas_per_rfc4180

-

Bug improvise-1cz. export_csv joined labels and values with bare commas — no RFC 4180 quoting — and ignored the view's number format. Under a comma-grouping format a value of 1200 renders as 1,200 and a label like Smith, Jr. splits into two fields, corrupting every row. The test reads the file back with a real CSV reader and asserts the field count, which is the thing a string contains check cannot see.

-

Strengthened by improvise-711n. The assertion on the value was records[1][1].starts_with("1,200"), and the fixture's format is ,.2f — so the check could not tell 1,200 from 1,200.00 and sat green while parse_number_format threw the .2 away. It is now an exact equality. Reverting the format.rs fix fails it with left: "1,200", right: "1,200.00", which is the cross-crate demonstration that the format bug reached a user's exported file and not only the screen.

-
-

#improvise-io/src/import/analyzer.rs

-

#detect_short_year_format

-

Two-digit years are ambiguous with the four-digit format, so %m/%d/%Y matches first. Expected, not a defect: the wizard shows the detected format and the user can override it. The same argument as the ambiguity below.

-

#an_ambiguous_date_is_read_american_first

-

01/02/2025 and 02/03/2025 parse under %m/%d/%Y and %d/%m/%Y, and nothing in the data can tell them apart. The heuristic is first-match over DATE_FORMATS, so the American reading wins — a European CSV imports with its days and months swapped, silently, and the derived _Month/_Quarter categories inherit the error. improvise-l8rh ruled this a known limit rather than a defect, so this test is what makes changing the answer a decision somebody makes rather than a side effect of reordering the table. The argument is in context/code/formula-and-io.md.

-

The loop at the end is the precondition that makes this a statement about ambiguity rather than about one format merely fitting: the other reading has to fit too, or there is nothing to resolve.

-

#a_day_first_only_value_settles_the_ambiguity

-

Where the two readings genuinely can be told apart, the unambiguous value decides — first match still, but %m/%d/%Y now fails. Without this, "always American" would pass the test above.

-

#the_distinct_values_are_in_first_seen_order

-

improvise-7yx, found while pinning the date heuristic. analyze_records built its distinct values by collecting into a HashSet and iterating it, so the order was different in every process. This is gotcha 18's bug in a second place, and it reaches further than a tidy-up: build_model adds items in exactly this order, IndexMap preserves insertion order, and persistence relies on it — so the same CSV imported twice produced two different .improv files and two different row orders on screen.

-

The answer is first-seen order: deterministic, and the order the records themselves are in, which is the one a user can predict. Five runs is enough to see it, because each HashSet gets its own RandomState; the pre-fix code produced five different orders here.

-

#the_detected_date_format_does_not_depend_on_the_hash_seed

-

The consequence of the same bug that a user would have found hardest to explain. detect_date_format tests only the first ten samples, so when the sample order was a hash order, which ten it saw varied — and with it the detected format, whenever the eleventh-and-later values are the ones that rule a format out.

-

Twelve values are ambiguous and one (25/12/2025) is day-first only. In hash order it landed inside the first ten about half the time, so the column imported as %d/%m/%Y on some runs and %m/%d/%Y on others — every date in it off by the difference. In first-seen order it is last, deliberately: the answer is %m/%d/%Y stably, which is the property asserted. The ten-sample truncation itself is unchanged and is still a real limit.

-
-

#improvise-io/src/import/csv_parser.rs

-

#short_rows_pad_missing_trailing_columns_with_null

-

Bug improvise-k8i. The reader is configured .flexible(true), so rows shorter than the header parsed without error — but the missing trailing columns were simply absent from the record map. No error, no warning, and field-kind analysis saw skewed value counts. Short rows must keep every header column, missing ones as Null.

-

#short_rows_produce_warning_naming_column_and_count

-

Companion to improvise-k8i: the warning must name the affected column and say how many rows were short. A warning that said only "some rows were short" would pass a weaker test and be useless to the user.

-

#full_rows_produce_no_warnings

-

The negative arm: padding must not fire on a complete file.

-

#a_leading_utf8_bom_is_not_part_of_the_first_header

-

A header name becomes a category name and survives into the .improv file, so a byte-order mark riding along invisibly is silent corruption rather than cosmetics. Excel writes one; the csv crate strips a leading one, so the first header is Region and not \u{feff}Region.

-

The control at the end is what makes this an assertion rather than a tautology: a BOM in the second header is not leading, is not stripped, and shows up in the key — so the check demonstrably can see a BOM where there is one.

-

#crlf_line_endings_leave_no_carriage_return_in_the_data

-

The \r belongs to the terminator, not to the last field of the row: a Revenue of 1\r would be a string rather than a number, and every row would import as text.

-

The control: a lone \r is itself a record terminator to this reader, so Ea\rst splits one row into two. That is what a stray carriage return does when it is not part of a CRLF pair, and it is why "two records, clean values" is a real observation rather than a coincidence.

-

#an_empty_file_is_no_records_and_no_warnings

-

An empty file is no records and no complaint. The wizard's own preconditions (wizard.rs::validation_error) are what refuse to build a model from nothing; the parser's job is to say truthfully that there was nothing there.

-

#a_header_with_no_rows_is_no_records

-

The same answer, and the case a user actually hits — an export filtered down to nothing.

-

#a_single_column_file_yields_one_field_per_record

-

The degenerate shape for everything downstream that splits a row into coordinates and a measure: one field, present, and not mistaken for a short row.

-
-

#improvise-io/src/import/wizard.rs

-

#wizard_previous_reverses_each_advance

-

Bug improvise-907 (from improvise-o1c): no step bound a back key, so reaching a later step and wanting to change a field kind meant Esc — discarding every proposal toggle, formula and name. previous walks back through the path actually taken, including the steps a conditional transition skipped on the way forward, which is why it is a history stack and not a reverse table.

-

#wizard_build_parks_the_workbook_on_done

-

Bug improvise-907 (from improvise-c6j): advance mapped NameModel → Done but nothing ever called it there, so Done was reachable only from tests. build now parks the workbook on Done for confirmation.

-

#wizard_build_failure_stays_on_name_model

-

A build that fails despite the earlier gates leaves the user on NameModel with the error, rather than moving to a Done step with nothing to confirm.

-

#wizard_blocks_advance_when_over_category_limit

-

Bug improvise-mzv: with more than MAX_CATEGORIES (12) accepted Category proposals, the wizard let the user advance through every remaining step and build_model() only failed at the final confirm. Advancing past ReviewProposals must be blocked, with a message telling the user to mark fields as Measure or toggle them off — the actions still available on that step.

-

#wizard_blocks_advance_when_no_category_accepted

-

Bug improvise-907 (from improvise-gfq): build_model raised "At least one category must be accepted" only on Enter at NameModel, the final step. An import whose fields all classify as Measure walked the user through DefineFormulas and NameModel before refusing. The gate belongs at ReviewProposals, where c can still change a field's kind — the same treatment improvise-mzv gave the too-many-categories case.

-

#build_model_null_category_value_is_missing_not_the_item_null

-

improvise-ych. A category value of JSON null used to be stringified by the v.to_string() fallback, inventing an item literally named "null" and placing the record under it. A CSV empty cell parses to Value::Null too, so every blank column value in a CSV import grew a phantom "null" item. Null means absent: the record must be skipped and counted, exactly as if the key were missing.

-

#build_model_literal_string_null_is_a_real_category_item

-

The other side of improvise-ych: the string "null" is an ordinary value — a region really called "null" — and must survive as its own item. Only Value::Null means absent. Without this arm, "drop anything that stringifies to null" would pass the test above.

-

#build_model_empty_string_category_value_is_missing

-

improvise-ych, third arm. An empty string is the JSON spelling of a blank cell — the CSV parser already turns a blank field into Value::Null, so the two inputs must agree. An item with an empty name is unnameable in the grid, so it is treated as missing rather than created.

-

#build_model_warns_about_records_skipped_for_missing_category

-

improvise-5j9: skipping a record for a missing category value used to be silent, so a 1000-row import could drop rows with no user-visible sign. The warning must name the count, the total, and the offending field(s) — a bare "some records were skipped" is not actionable.

-

#is_does_not_swallow_the_start_of_a_longer_word

-

Adversarial review of PR #54, low severity and fixed anyway. is_expr was identifier ~ ^"IS" ~ filter_value, so Region ISLAND parsed as Is(Region, "LAND") and Region isotope as Is(Region, "otope") — inputs that were parse errors before the production existed, now silently-wrong formulas. Gotcha 34's whole theme is that a silently wrong parse costs more than a refusal.

-

The repair went through two wrong shapes before the right one, and both are worth keeping. A negative lookahead ^"IS" ~ !(ASCII_ALPHANUMERIC | …) in an ordinary rule cannot work: pest auto-inserts WHITESPACE between sequence elements, so it tested the character after the space — rejecting Region IS East while still accepting Region ISLAND, i.e. exactly backwards. Making is_expr compound-atomic ($) stops the auto-insertion and forces the separator to be written out as sp+.

-

At which point the lookahead was redundant and was removed, which only re-injecting the fault revealed: deleting !bare_char left the suite green, because sp+ after IS already refuses ISLAND — IS matches, then sp+ demands whitespace and finds L. A guard nobody has seen fail is indistinguishable from one that always passes (doctrine(check_the_checker)), so it went. sp+ is now the mechanism, and relaxing it to sp* — the obvious "let people write Region IS|East|" change — reopens the hole; that substitution is what this test was finally proved against. The negative arm is paired with a positive one, or "reject everything" would pass.

-

^"WHERE" had the identical hole. It is closed now — see below.

-

#where_does_not_swallow_the_start_of_a_longer_word

-

improvise-r08u, the WHERE half of the same defect. where_clause and inline_where were ordinary rules, so pest auto-inserted WHITESPACE after ^"WHERE" and SUM(Rev WHEREAS x = y) parsed as a WHERE on a category AS: a silently wrong filter where it should have been a syntax error.

-

The test carries two negative shapes because the repair has two independent halves, and this was found by injecting each separately. Making the rules compound-atomic alone already refuses WHEREAS x = y — once implicit whitespace is gone, AS x cannot be one identifier — so a test built only on the ticket's reproducer passes over a rule still spelled sp*, and WHEREAS=y is what that rule still reads as a filter. Faults injected: the original non-atomic rule (fails on the first assertion) and sp+ relaxed to sp* (fails on the second). The positive arms are there so "refuse every WHERE" cannot pass.

-

#where_requires_a_space_after_the_keyword

-

The one spelling sp+ costs, stated rather than discovered later: WHERE|Region Name|=|East Coast| used to parse and now does not. IS has required its space since improvise-4zl7, so this is the two keywords agreeing. The paired positive arm — the same text with the space — is what stops it degrading into "any WHERE with a quoted category fails".

-

#the_grammar_is_parsed_once_however_many_files_the_strategy_yields

-

improvise-8shu. improvise-io's lib binary was the run's outlier — 1.42s here against a 0.01s median, measured at 1.40–1.45s, 1.50s and 3.70–5.24s on three machines. The ticket's diagnosis was that the grammar walk had no size bound, so a few deeply nested documents dominated. That was wrong, and measuring before fixing is the only reason it did not ship as the fix: instrumenting the two halves separately gave load_grammar() ×1000 = 2.50s and the walk itself ×1000 = 15.7ms. The walk was 0.6% of the cost. improv_file() called load_grammar() inside its prop_map, so pest_meta re-parsed improv.pest once for every generated case — 1000 times across the two properties, plus shrink attempts.

-

The strategy's input space was never the problem and is unchanged: the same entropy range, the same 0..N repetitions, the same properties over groups, formulas, views, hidden items and pipe quoting. The grammar is now read once through a OnceLock.

-

The guard counts parses rather than calls, which is what makes it order-independent: other tests in the binary reach grammar() too, and the count stays 1 however many of them run first. It drives improv_file() itself through a TestRunner rather than re-implementing what the strategy does, so a future edit that reverts the call site is what it catches. Fault injected: Gen::new(grammar(), …) restored to let rules = load_grammar(); — the test fails with left: 64, right: 1.

-

Case counts went 500 → 256 in the same file, which is the default context/design-principles.md §7 prescribes and never below it; that is worth a further 0.39s → 0.21s but it is not the fix, and reporting it as one would be the mistake this entry exists to record. Net: 1.42s → 0.21s, and improvise-io is no longer the run's longest binary.

-

#export_csv_omits_the_rows_the_view_prunes

-

improvise-iot6. render_csv built its layout with GridLayout::new, which does not apply prune_empty — only GridLayout::with_frozen_records does. So :export in the TUI and the CLI export path both wrote rows the grid does not draw. User-visible, in production, and shipping for as long as the two constructors have differed.

-

This is the fourth instance of the same trap; the third was project::grid_in, fixed in PR #78, and this fix is that one line for line. No fault needed injecting: on the unfixed tree the test fails with

-

CSV wrote a row the grid does not draw: left ["Food", "Gas", "Rent"], right ["Food", "Gas"]

-

Why the fixture is discriminating, which is the whole difficulty here. The sibling property written for improvise-t6db passed on the buggy tree in its first version, because it made its two views differ by turning pruning off — plenty of differing config, and vacuous, since with nothing to prune both code paths agree. So this fixture does two things that one cannot fake:

-
    -
  • it contains a row that is actually empty (Rent, an item of Type that no cell names) and leaves prune_empty ON;
  • -
  • it asserts the precondition in both directions before exporting anything — with_frozen_records draws 2 rows and new holds 3. If a future change makes pruning a no-op, or makes the two constructors agree, the precondition fails and says so rather than the assertion passing vacuously.
  • -
-

None of export.rs's three pre-existing tests mentions pruning at all, which is exactly why this shipped.

-

Both axes, because prune_empty prunes both. A first version of this fixture had a single Month, so it demonstrated the row half and left the column half asserted by nothing -- the same defect one axis over. Feb is now the empty column, the precondition is a pair on each layout ((2, 1) drawn against (3, 2) unpruned), and the CSV header is asserted to carry Jan alone.

-
-
- - diff --git a/site/docs/test-evidence/gp.html b/site/docs/test-evidence/gp.html deleted file mode 100644 index 5890b0c9..00000000 --- a/site/docs/test-evidence/gp.html +++ /dev/null @@ -1,232 +0,0 @@ - - - - - -gp-tree and improvise-gp — improvise docs - - - -
- -
-

Source: context/test-evidence/gp.md

On this page

Test evidence: gp-tree and improvise-gp

-

What each test's bug was, what fault was injected to prove the test can fail, and why the obvious weaker version of it proved nothing. Keyed by exact test name — a rename orphans the entry, which scripts/check-comment-runs.py --check-evidence catches.

-

The architectural arguments these tests defend are in context/code/gp.md.

-
-

#gp-tree/src/lib.rs

-

#splitmix64_matches_the_reference_vectors

-

The vendored RNG's constants had nothing pinning them. Changing the gamma from 0x9E3779B97F4A7C15 to …7C17 left every other test in the workspace green, because they only ever assert that two runs at one seed agree.

-

The crate's promise is stronger: a fit's seed is printed to the user and saved in data::Model, so a later run at an old seed must reproduce the old fit. These are therefore the published SplitMix64 vectors for seed 0, not a golden capture of this code — which is what makes them evidence rather than a tautology.

-

#splitmix64_matches_the_reference_vectors_from_a_nonzero_seed

-

Seed 0 alone cannot catch a change to how the seed enters the state (state = seed versus state = 0), because both give the same stream at 0. Also asserts seed() still answers the seed after the generator has advanced.

-
-

#gp-tree/src/fit.rs

-

#mse_averages_over_exactly_the_rows_it_was_given

-

mse used to take parallel x/y slices, sum over zip(x, y) — which stops at the shorter — and divide by x.len(). A caller that got the two out of step was handed a mean that was quietly too small rather than an error: the improvise-n3wt hazard, a count supplied alongside the data rather than taken from it.

-

The remedy was not a length check but a type: one slice of Row, so the shorter slice cannot be built (improvise-dkqn). This test is what is left to say about it — numerator and denominator count the same rows, for any prefix.

-

#scoring_no_rows_is_zero_error_rather_than_nan

-

Every mean in the module reached 0.0 / 0.0 on an empty slice and handed back a NaN a caller could not tell from a score: it propagates through FitConfig::cost and sorts unpredictably against real costs. One answer, in mean_over, covers mse, misclass_rate and standardize, and the test asserts all three plus std == 1e-6 (a degenerate spread still never divides by zero).

-

#asking_for_no_folds_yields_one_rather_than_dividing_by_zero

-

k reaches folds from a CLI flag, so zero is a value a caller can supply rather than an impossible state. out[pos % k] divided by it and panicked — a public library function taking its host down with it.

-

#cross_validating_without_a_held_out_fold_pools_nothing

-

The sharper half of the same parameter (improvise-ry89). A cross-validated prediction is one made by a tree that never saw the row it scores, so k < 2 asks for something that cannot exist: the single fold holds every index, the training complement is empty, and the tree is fit and standardized on nothing before predicting every row.

-

This used to announce itself — standardize(&[]) gave a NaN mean and every pooled prediction came back NaN. Once mean_over answered the empty row set with 0.0, the same call handed back near-zero numbers indistinguishable from a model's output, and an R² computed off them is a number rather than a NaN. So fixing one bug hid another, and the count is the contract: no held-out row means no pair. The test also asserts the generator is untouched, because declining before the shuffle is what keeps a caller's next draw the one it would have made.

-

#the_training_split_is_what_the_contains_scan_gave

-

The O(n²) fix (improvise-t3ux) replaced test.contains(i) per row with a bool mask. It is only safe because the mask yields the same indices in the same order — the training rows are what every downstream draw from the generator depends on. Pinned directly, at every fold count, rather than inferred from a fit that happened to agree.

-

#a_fit_improves_on_the_tree_it_started_from

-

Without it, a fit that returned its seed tree unchanged would satisfy both the_same_seed_and_config_fit_the_same_tree and a_different_seed_fits_a_different_tree, because the initial random_tree already differs across seeds.

-

#fit_detailed_starts_from_the_best_of_generation_zero

-

What start has to mean for the question it exists to answer: the best of generation 0, not the seed ancestor it grew from (improvise-ut7p). The two differ whenever a mutated member of the initial population beats member 0, and only the former makes "the search improved on where it started" a claim about breeding. Before this, improvise-gp kept a second copy of the whole GA loop just to reach that starting point.

-

#a_tiny_lone_importance_is_still_the_whole_of_it

-

Copilot on PR #25. fit_csv divided by total.max(1e-9), which is right for the only input it ever had and wrong as a contract: one feature carrying an importance of 1e-10 carries all of what the model knows and was reported at 10%. Importances this small are ordinary — they are error differences over a standardized target. the_shares_sum_to_a_hundred_at_any_scale is the general form across four orders of magnitude.

-

#denormalize_rescales_the_leaves_and_leaves_the_splits_alone

-

The negative arm is the load-bearing one: standardize rescales the target, so a threshold is already in its feature's own units and re-scaling it with the leaves would be the bug. a_tree_fitted_on_standardized_rows_predicts_original_units_after_denormalize pins the arithmetic against a hand-built tree rather than a search, so it does not depend on the GA.

-
-

#gp-tree/src/data.rs

-

#an_entirely_missing_column_is_skipped_and_reported_as_data

-

preprocess used to say so with an eprintln!, which was correct while this code was examples/fit_csv.rs. A library does not own its caller's stderr: a TUI on an alternate screen, a wasm build, or a caller wanting the notice in its own status line could none of them suppress, redirect or translate it. The notice now comes back as data.

-

#seed_zero_is_distinguishable_from_no_seed

-

0 is a legal seed, so "unknown" and "fitted at 0" have to be two different values — anything reporting the seed of a pre-seed model would otherwise state a seed that never ran. That is why Model::seed is an Option and not a sentinel.

-

#a_saved_model_round_trips_with_its_whole_ga_config

-

The seed alone is necessary and not sufficient: the same seed under a different FitConfig searches differently and lands somewhere else, so a model carrying only a seed looks reproducible while being reproducible only by whoever still remembers the flags.

-

The fixture a_fit_config() differs from FitConfig::default() in every field, and the test asserts that explicitly. A round trip that silently substituted the default would otherwise pass against a default-constructed value — the failure mode this test exists for.

-

#a_model_saved_before_configs_still_loads

-

A backward-compatibility test aimed at the absent field, not at the attribute. #[serde(default)] on an Option is documentation rather than mechanism — serde defaults an Option anyway, so a test that merely deletes the attribute fails nothing and is not discriminating. This one removes config from the serialized JSON and asserts it loads as None (unknown), not as Some(FitConfig::default()). a_model_saved_before_seeds_still_loads is the same shape for seed.

-
-

#gp-tree/tests/scoring_known_answers.rs

-

Whole-file note: these are f32 bit patterns, not approximate values, because a change that merely moves an accumulation around silently re-points every seed already saved at a different tree. assert!((a - b).abs() < 1e-6) cannot see that, and neither can the CLI's output, rounded to three or four decimals. The numbers were captured from the parallel-x/y code before improvise-dkqn reshaped the signatures and improvise-t3ux replaced the contains scan, and are unchanged by both. If you are changing the search on purpose these numbers change with it — but then every seed recorded by an older --save names a different tree, and that is the decision this file forces you to make out loud.

-

#standardize_is_bit_for_bit_what_it_was

-

A witness that could not see the bit. The first and last rows plus mean/std were the whole witness, and four numbers cannot separate two ways of computing the same quantity: injecting r.target / std - mean / std for (r.target - mean) / std — algebraically identical, a different rounding — left this test and both cross-validation vectors green (improvise-ry89). All thirty-seven values do separate them: that reassociation moves 21 of the rows, and none of them were rows 0, 36 or 19.

-

Note what is not evidence here. Reversing the mean's accumulation order also left every vector green, but on this fixture the two orders sum to the same f32 — so that injection changed no behaviour to catch. A perturbation has to move a bit before a bit-level witness can be blamed for missing it.

-

#cv_regress_pools_the_same_predictions_and_leaves_the_rng_where_it_was

-

The rng_after witness is the part that pins the order of the generator's calls: the next u64 after a cross-validation depends on exactly how many draws that run made, so a reshaped loop that drew differently would move it even if every pooled prediction happened to agree. 37 rows is a prime, so no fold count divides it evenly and the last fold is short — where an off-by-one in the train/test split shows.

-

#cv_classify_pools_the_same_codes_and_leaves_the_rng_where_it_was

-

The classification half of the same guard. The mask replaced contains in both loops, and one arm passing says nothing about the other (doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)).

-
-

#improvise-gp/src/lib.rs

-

#improvise_evaluation_matches_gp_tree_eval

-

The acceptance property: over random trees and random feature vectors, improvise's evaluation of the emitted formula matches gp_tree::eval exactly — widening f32 to f64 is lossless and the thresholds are exact, so there is no tolerance to allow.

-

#The improvise_eval helper, and the shape to remember

-

This test and a_value_sitting_on_the_threshold_falls_the_same_side both run through the improvise_eval helper, and its scope is what was once read as covering more than it does. It fabricates a _Measure item per feature name, so it asserts the emitter and gp_tree::eval agree against a model that could not exist. It says Ok to outlook=rainy because it just made an item called that. A helper that builds the world the code wants is not evidence that the world exists.

-

So the awkward names above are a quoting fixture and nothing more: outlook=rainy is there because it is awkward to spell, not because a document could hold it — it could not, and fitted_formula refuses it. The helper is still the right tool for the arithmetic-and-quoting claims; the property it could not state is asserted separately, against a real document, in evaluability_in_a_real_document.

-

#an_awkward_target_name_is_quoted_and_round_trips

-

This test used to assert parsed.target == "|Total Revenue|" and call the pipes correct. They are not: the target names a _Measure item, so a target carrying its delimiters writes the fit into an item literally named |Total Revenue|, which is not the item the user has. What the emitter owes its caller is a semantic round trip — the name goes in, the same name comes back out. an_awkward_target_names_the_users_measure_item is the consequence that makes it a data bug rather than a cosmetic one.

-

#an_infinite_threshold_is_refused_rather_than_emitted_as_inf

-

number_text(f32::INFINITY) emitted "inf", which "inf".parse::<f64>() accepts — so the round-trip loop returned it and a formula_text caller got text the grammar rejects, with nothing on the way saying so. fitted_formula would surface it as a parse error; a bare formula_text caller would write it into a file.

-

#a_nan_threshold_is_refused_by_name

-

NaN fell through the loop to unreachable!, so the panic blamed the decimal expansion instead of the caller's value.

-

#a_threshold_is_the_shortest_decimal_that_reads_back_the_same

-

The doc used to overclaim the emitted text as the f32's full decimal expansion. It is the shortest decimal that reads back as the same f64 — the weaker claim, and the one the routing argument actually needs: equal text means equal f64 means the same branch.

-

#a_value_sitting_on_the_threshold_falls_the_same_side

-

The boundary case the whole number_text design exists for. 0.7f32 is really 0.699999988…; the shortest text that reads back as that f32 is "0.7", which parses as an f64 strictly greater than it. A row whose feature value equals the threshold routes hi under eval and would route lo through a formula carrying the rounded threshold. The test asserts the trap is live — "0.7".parse::<f64>() > thresh as f64 — before relying on it.

-

#a_split_on_a_one_hot_feature_is_spelled_as_a_coordinate

-

improvise-8xhy's defect, and improvise-4zl7's answer to it. preprocess one-hots a categorical column into features named Region=North, an encoding artefact no document has an item for, so find_item_category returned None and every cell of the fitted measure came back as Error("ref:Region=North") — silently, with the emitter having said nothing. 8xhy shipped a refusal; 4zl7 replaced the refusal with a spelling, Region IS North.

-

The assertion is the emitted text, exactly, and not merely that the call is Ok: the whole failure mode here was a well-formed formula meaning the wrong thing, so "it parsed" is the assertion that missed it the first time. The fault to inject is Spelling::Coordinate rendering as quote_name(name) — byte-identical for every measure, and wrong for exactly this family.

-

#a_coordinate_naming_no_category_of_this_document_is_refused

-

The negative arm of the same rule, and it is not redundant with the artefact refusal: a Coordinate is spellable and may still be unresolvable. Without it, resolves_in's Coordinate arm could return true unconditionally and every test above would still pass.

-

#a_measure_that_merely_looks_like_a_one_hot_is_accepted

-

The negative arm, and why the check asks the document rather than refusing names that look like encoding artefacts: a measure literally named Region=North is a legal document. The two are indistinguishable as strings. the_missing_flag_and_the_date_sub_features_are_refused_too covers the other two artefact families with nothing enumerating them — the check asks the exact precondition Expr::Ref has, so a future naming scheme is covered without this crate learning about it.

-

#a_formula_the_emitter_accepted_evaluates_everywhere

-

The property the lying test could not state: if fitted_formula says Ok, no cell of the target is a ref: error. It is also what stops the refusal tests above from passing by refusing everything.

-

#a_tree_indexing_past_the_spec_says_so

-

improvise-hkt6. A tree whose feat runs past the spec violates the same precondition in two places, and the two answered differently: expr_text said what was wrong, mark_features said index out of bounds. Whichever ran first decided what the caller was told, and fitted_formula runs mark_features first — so the legible message was the one nobody ever saw. Driven through fitted_formula because both private functions are reached that way, and the message is asserted rather than merely the panic, since an out-of-bounds index panics too.

-

#a_record_added_after_the_fit_is_scored_by_the_formula_alone

-

The load-bearing half of improvise-7na's answer: scoring a new row needs no saved encoding plan. fit_csv --predict calls encode_row because its new row arrives as untyped CSV text; improvise's new row is a record in the same document, scored through the stored formula from its own measures. So nothing about the --predict path argues for putting a gp_tree::data::Model in the file.

-

#breeding_improves_on_generation_zero

-

What rules out a GA whose breed returned its input unchanged. a_different_seed_fits_a_different_formula is weak evidence on its own, since the initial random_tree already differs across seeds.

-
-

#improvise-gp/src/dataset.rs

-

#a_measure_on_the_page_axis_does_not_filter_away_the_training_rows

-

improvise-vx3. Workbook::new's default view puts _Measure on Axis::Page, so filtering_cats() named it, filter_coords pinned it to the first measure, and every "record" then carried exactly one measure cell — never the target. The whole table came back as "no visible record has a Revenue value to learn from" on a document plainly full of them, which reads as a data problem rather than as an axis one.

-

Every test in the module used View::new + on_category_added, under which a leading-underscore category lands on Axis::None — so the fixture was quietly building the one world the code worked in, and no test could see the default a real Workbook hands out.

-

The assertion compares the contents, not the shape. Pinning only the header and the row count passes on this fixture even with the bug present: _Measure's first item is Revenue, so every row still has a target and only the feature columns come back blank — the shape survives while the data does not.

-

#a_paged_measure_that_is_not_the_target_does_not_empty_the_table

-

The sharper half: filter_coords pinned to the first item, so on a document whose target is not first the table came back empty.

-

#the_feature_table_is_one_row_per_record_with_the_target_last

-

The literal row order is half of what this pins, and f32 accumulation over the rows is why: a reordered table makes a seeded fit irreproducible in a way nothing prints. Since improvise-shyt matching_cells yields insertion order, so the assertion now holds by construction; before it, a test in one process could not tell "sorted" from "this process happened to hash them in that order".

-

#a_formula_measure_is_not_a_feature

-

The fixture lists Profit as an explicit _Measure item as well as making it a formula target, which is legal and is the only case this test can see: a target that is only implied by its formula never reaches the explicit item list, so a fixture without the explicit item passes whether the rule is implemented or not.

-
-

#improvise-gp/src/fit.rs

-

Whole-module fixture note — document(). Every category is a Label, and that is the only thing that makes the success path deterministic. An earlier version left Region regular with a single item and claimed the constant one-hot had a degenerate (1, 1) range that simplify would prune. That claim is false: data.rs::build_categorical pushes (0.0, 1.0) for every one-hot whatever the column contains, so simplify cannot know it is constant and the champion was free to split on Region=North. These tests were passing on seed luck, and the comment asserting otherwise was the worst part (Copilot on PR #25). The targets are in the thousands so a fit that forgets to de-normalize is visibly wrong rather than subtly so. The view puts _Measure on Axis::Page because that is what Workbook::new does — fitting under the real default is not an extra case, it is the only honest one.

-

#the_fitted_formula_predicts_in_the_documents_own_units

-

The de-normalization regression. fit runs against a standardized target, so a champion written down untouched predicts standard deviations: a well-formed formula, parsing cleanly, evaluating to ±5 where the user's data is 1000–4000. Nothing downstream can see that — it is a number in a cell.

-

Fault injected: dropping the denormalize call in fit_measure fails this and only this test. The loop panic!s rather than continues on a non-numeric cell, because a loop that skips every record asserts nothing and "no cell evaluated" is itself the improvise-8xhy failure. The claim is then read a second way off the formula text, which does not depend on evaluation at all.

-

#a_category_and_a_measure_of_the_same_name_are_refused

-

Copilot on PR #25, and the sharpest finding of the round: asking the document whether it can resolve a feature name is not the same question as asking whether that name means the column the tree was fitted on. A numeric category called Cost encodes to a feature called Cost; with a _Measure item of the same name the emitted Expr::Ref("Cost") resolves cleanly — to the measure — and the stored rule silently reads a different column from the one it learned.

-

The first version of this test never built the collision. Adding a category Cost with items puts no Cost coordinate on the existing records, so feature_table emits an empty column, preprocess drops it, and there was no duplicate name to find; it passed on the positional role bug instead. The records here carry the coordinate, and the test asserts the fixture really produces two columns named Cost before asserting the refusal.

-

#an_empty_category_before_a_measure_does_not_break_the_role_check

-

Copilot on PR #25. preprocess continues past an entirely-missing column without pushing a ColumnSpec, so Dataset::columns is not index-aligned with the header once anything is dropped. A positional i >= category_columns role test then read the surviving numeric measure as a category and refused a perfectly good fit. The test asserts the fixture really drops a column first.

-

#a_coordinate_is_spelled_as_one_even_when_an_item_shares_the_one_hots_name

-

The collision a name cannot resolve, now closed by construction rather than by a refusal. The fixture adds a _Measure item literally named Region=North and asserts find_item_category resolves it — so a check that matched the one-hot's feature name against the document would have found the wrong column.

-

Under improvise-4zl7 the one-hot is never spelled by its feature name at all: Features::of reads the role off preprocess's plan and emits Region IS North, which cannot collide with any measure however the document is named. So the assertion is the absence of |Region=North| in the emitted text as much as the presence of Region IS. Injecting the fault means having spell fall through to Spelling::Measure for a OneHot role: the fit then succeeds, the formula reads |Region=North|, and it silently evaluates against the wrong column — which is the original bug wearing the new API.

-

#a_high_cardinality_numeric_measure_is_one_feature_not_many

-

The preflight approximates preprocess's type inference, so it can disagree in both directions. A numeric measure with more distinct values than MAX_FEATURES encodes to one feature and must not be refused for having many values.

-

#a_category_with_a_distinct_value_per_record_is_refused_not_searched

-

FitConfig bounds the search; nothing bounded the width. A regular category with one distinct value per record one-hots to one column per record, and permutation importance is repeats × rows × features evaluations on the draw thread.

-

The fixture had to be pushed past MAX_FEATURES, and the assertion names the width refusal specifically. At twelve records this document is refused too — but by fitted_formula noticing the champion split on Tag=r0, a different rule, which would have made the test pass with the guard removed entirely.

- -

The width bound does not bound the work: every stage scales with rows. The assertion also requires the message to say what to do (Page or Filter).

-

#a_document_with_no_usable_feature_is_refused_rather_than_panicking

-

preprocess drops an entirely-missing column, so a document whose only candidate feature is empty encodes to zero features — and random_tree then draws rng.below(0), which SplitMix64 answers with 0, and sample_thresh indexes spec.ranges[0]. A panic, on a legal document, for any seed that does not happen to open on a leaf.

-

This needs the all-Label fixture rather than merely an empty measure: with a regular category present its one-hot survives — range (0.0, 1.0) whatever it holds — and the fit returns a constant leaf instead of crashing.

-

#a_non_finite_cell_is_refused_rather_than_panicking

-

A .improv cell holding inf parses as an f64, so this is reachable from a document rather than only from a bug. standardize turns an infinite target into NaN leaves, an inf..inf range makes sample_thresh produce a NaN threshold, and number_text then asserts — a panic out of a function whose signature promised a Result.

-

Verified by disabling the guard, and the two directions differ: an infinite target really does panic in number_text ("wants a finite value, got inf"), while an infinite feature poisons its range and quietly yields a constant leaf. A panic and a meaningless rule; both are refused.

-

NaN is deliberately absent from the cases: f64::NAN.to_string() is "NaN", which is_missing already spells as missing, so it is imputed away and never reaches the search.

-

#a_name_containing_a_newline_cannot_split_the_status_line

-

.improv pipe quoting makes a newline legal inside a name, so a document can hold a measure whose name would split the TUI status bar in two.

-

#a_fit_that_needs_a_category_now_spells_it_as_a_coordinate

-

improvise-8xhy reaching a real user, and what improvise-4zl7 bought: the same fixture, the same champion, and a formula instead of a refusal. The fixture clears both measures on every North record: clearing only Cost would leave North carrying both target values, so the one-hot would explain nothing and the champion would have no reason to split on it.

-

The second assertion is the one that earns its place. Checking only that the text says Region IS would pass for a rule that routed both groups to the same leaf — which is what a tree that could not see the coordinate would produce. The fixture's two groups are 1000 and 4000, so requiring one leaf under 2000 and one over 3000 separates "spelled it" from "spelled it and meant it".

-

The \n-and-double-space assertions moved with the refusal they guarded; the one-line rule itself is unchanged and is still asserted by a_name_containing_a_newline_cannot_split_the_status_line.

-

#the_fold_count_moves_the_champion_and_so_is_recorded

-

Copilot on PR #25, and it is right: the outcome used to carry seed plus config, which is the pair gotcha 37b names — and that pair is not sufficient here. Cross-validation draws from the same generator the fit then draws from, so changing cv_folds alone moves the champion. Recording two of four fields documented the reproducibility contract without meeting it. The test asserts the seed and config are equal across the two requests first, so the difference can only be the fold count.

- -

The knob varied is pop, not generations. More generations over an exactly-learnable fixture can land on the same optimum and simplify normalises it, so that version of the test would fail one day for a reason that is not a regression. pop changes how many RNG draws each generation makes, so the two searches diverge from the first one.

-

#a_fit_named_after_a_category_item_is_refused

-

Copilot on PR #25. The check used to ask only about measures, and Expr::Ref does not: find_item_category finds a regular category's item before falling back into _Measure (gotcha 7). So naming the fit after some category's item installed cleanly and then made every later formula referring to it read the category coordinate. a_later_formula_referring_to_the_fit_reads_the_fit is the property that refusal protects, asserted against a real document.

-

#a_fitted_rule_at_an_aggregated_cell_routes_per_record_and_sums

-

The characterization PR #25 left here, now replaced by the rule it was waiting for. A tree is fitted on individual records; an improvise formula is evaluated at a CellKey, and evaluate_aggregated used to aggregate each Expr::Ref first and evaluate the IF once on the totals. Linear formulas are unaffected — SUM(Revenue) - SUM(Cost) is SUM(Revenue - Cost) — and a tree is not, so the stored rule meant one thing on a record cell and a different thing one row up. improvise-4zl7's ruling was to aggregate the rule over the members.

-

The old test asserted the negative and carried a message telling whoever broke it to replace it with the rule. It fired on the first run of the fold.

-

What this one asserts is the property the ticket names: the aggregated cell equals the sum of the per-record predictions. That compares two different code paths — a fully-specified key evaluates one member, an aggregating key folds twelve — rather than one path against itself, but it is still an end-to-end agreement rather than a hand-computed number. The hand-computed evidence is in improvise-core, under route_then_aggregate_tests, on a four-record fixture whose every expected value is written out in the test name or the assertion.

-

The third assertion is what stops this one being satisfiable by accident: routing once on the total lands on a single leaf, so the test refuses a fixture in which a leaf happens to equal the right answer.

-

#a_missing_flag_a_date_part_and_a_measures_value_are_refused_differently

-

improvise-wd6n. Spelling::Artefact was one variant standing for five distinct families — Missing, DatePart, a one-hot over a measure column, the (missing) pseudo-item, and a Numeric role over a category column — the last five reached through a _ => wildcard in spell. The single refusal they all fed said the tree "splits on a col.missing flag or a date part", so three of the five were declined in words naming something they are not. A user fitting over a text-valued measure column was told about missing-value flags.

-

The test drives Features::of over a real preprocess output rather than fabricating Spellings — which is the point, since what is under test is spell's classification, and the helper artefacts() beside it fabricates exactly what spell decides. That helper now takes the family as an argument so a fabricated spelling at least names one on purpose; Features::of is still what really decides, and this test is the one that asks it.

-

Each family's assertion has two clauses, and the second is what earns it: the error must contain that family's reason and not the other two's. The first clause alone passes against one message naming every family at once, which is precisely the state the ticket describes. Fault injected: the five arms of Artefact::reason collapsed to one shared string — the test fails on the first family, and with the injection removed all three arms pass.

-

The fixture reaches three families from one preprocess call (a text measure column, a measure with a hole in it, a column of ISO dates), which is why the module is named for what it reaches rather than for "each family". The remaining two are not reachable from it and are not asserted here. AbsentCoordinate is a permanent refusal; NumericCategory was not, and the first draft of this entry and of gp.md §11 both called it one — review found that fit_measure simply never tells preprocess which columns are categories. improvise-zv8t has since closed it (below). Worth recording as the failure mode: the branch that invented a classification also wrote the prose declaring one of its arms unfixable, and nothing but a reader could have caught that — the suite was green on either reading.

-

#fit::tests::a_category_whose_items_are_numbers_is_still_spelled_as_a_coordinate

-

#fit::tests::a_category_whose_items_are_dates_is_still_spelled_as_a_coordinate

-

improvise-zv8t. fit_measure handed preprocess an Overrides::default(), so it never said which header columns are document categories — although the very next line told Features::of. preprocess infers Numeric → Date → Categorical from the values, so a Year category with items 2024/2025 became a numeric column and a category of ISO dates became a date column, and both were refused as encoding artefacts with Year IS 2024 spellable all along. User-visible: a fit over such a document could not be stored at all.

-

No fault had to be injected, because the bug was live. On the unfixed tree both tests fail inside fit_measure with the real refusals, which are the two arms the ticket named and are worth quoting:

-
    -
  • ["Year"] names a category whose items all parse as numbers, so a reference to it would resolve to a _Measure item of the same name
  • -
  • ["Year.epoch_day", "Year.year"] is a part of a date, and date arithmetic is something improvise does not have anywhere
  • -
-

The second is why gp.md §11's date decline was narrower than it read.

-

Why each fixture is discriminating. year_document gives the model one regular category and no other feature column, and makes the target a function of that category alone — so a tree that does not split on Year is a poor fit and the GA does not settle for one. That matters because the refusal is conditioned on used_features: a fit whose champion happened to be a bare leaf would be accepted on the unfixed tree and prove nothing. Asserting formula.raw.contains("Year IS") rather than merely is_ok() closes that hole in both directions. Note what the unfixed tree actually does, because a first draft of this line got it wrong: it produces no text at all — fitted_formula refuses before emitting, which is what the messages quoted above are. The only unfixed path that emits anything is the bare-leaf champion named in the previous paragraph, and its text is SUM(<constant>), carrying no Year in any spelling. So the assertion closes the hole; "it would have said Expr::Ref(Year)" was a guess about a branch that does not run.

-

The numeric test then adds the property the refusal exists to protect: the formula goes in through add_formula and every record evaluates to a Number, not Error("ref:…") (improvise-8xhy).

-

#fit::tests::the_preflight_counts_a_numeric_category_at_its_cardinality

-

The other half of improvise-zv8t, and the one that is a cost rather than a fix. Forcing a category categorical widens it from one numeric feature to one per distinct value, so encoded_width — the cheap preflight that exists to refuse a too-wide table before preprocess materialises it (§14) — had to learn the same header[..category_columns] slice, or it would under-count exactly the column this ticket is about.

-

Fault injected: the col >= category_columns clause dropped from encoded_width, leaving the pre-ticket inference. The test fails by name, and the failure message is the evidence — it shows the exact check speaking where the preflight should:

-

the PREFLIGHT must refuse it, before anything is materialised: fitting "Score" encoded 70 features, over the 64 this host will search.

-

A test asserting only expect_err would have passed under that injection, since both guards refuse; the discriminator is which message, because the two guards differ in what they have already paid for. The fixture also asserts its own precondition — that Year really is the header's first column — so a feature_table change that reorders the header fails here rather than silently slicing the wrong names.

-

#fit::tests::a_wide_numeric_category_now_refuses_a_fit_that_used_to_ignore_it

-

The cost of improvise-zv8t, pinned rather than argued — and it exists because the first draft of gp.md §11 claimed there was none. That draft said "no document that fitted before stops fitting", reasoning that a tree splitting on a numeric category was refused as an artefact anyway. The refusal is conditioned on used_features, so a fit that never split on the column succeeded, and the wide columns are exactly the ones a fit has least reason to split on.

-

Measured both ways with a throwaway #[ignore] probe before this test was written, on a fixture of 100 records with a 100-item numeric Year beside a Cost that predicts Score on its own:

-
    -
  • pre-fix: FIT LANDS -> |Score (fit)| = SUM(IF(Cost < 48.0150032043457, 970.3…, 3684.7…)) — Year never appears.
  • -
  • post-fix: REFUSED -> fitting "Score" would encode 101 features from 100 records.
  • -
-

So no fault needed injecting; the change itself is what the test observes, and it fails on either side if the behaviour moves. The second half is what keeps it from being a bare characterization: it marks Year a Label and asserts the fit then lands on Cost, which is the repair the refusal's own message names. A test asserting only the refusal would leave that advice unchecked — and advice in an error message is a promise, so it is the half worth asserting.

-

The generalisable part is the review finding, not the test: prose claiming a change has no cost is a claim, and it is checkable. The suite was green with the false sentence in place, exactly as it was green with improvise-wd6n's "permanent refusal" — the same section, the same failure mode, one paragraph apart. See improvise-e8no for the case the trade genuinely loses.

-

#fit::tests::a_duplicate_column_name_is_refused_before_the_matrix_is_built

-

Found by adversarial review of improvise-zv8t, and introduced by it: as_categories forces by column name, while encoded_width counts by column index. A document may legally name a regular category and a raw measure the same thing -- a_category_and_a_measure_of_the_same_name_are_refused already ships that fixture -- and then the measure column is forced categorical by the name match while the preflight still counts it numeric by index.

-

Measured on the fixture here (40 records, a 40-item Dup category beside a Dup measure): the preflight answers 41 where forcing materialises 80. The refusal that catches the document, no_ambiguous_column_names, ran after preprocess, so the expansion the preflight exists to prevent was allocated and only then refused. At the default max_records = 20_000 that is a 20k-row one-hot matrix built before the error -- doctrine(guard_the_cost_not_the_result) defeated by an inconsistency the ticket itself introduced. The bounded drift that doctrine(a_budget_check_is_advisory_or_exact_and_must_say_which) licenses is approximation, not this.

-

The repair is a data dependency, not a moved line. as_categories now runs the check itself and returns Result<Overrides>, so the Overrides that preprocess requires cannot exist until the header has been accepted. That is doctrine(an_invariant_is_made_unrepresentable_not_validated) -- the answer prune_axis_items, Row<T> and Checked each got.

-

So the regression guard is the signature, and the injection is a compile error. Swapping the two statements back gives error[E0425]: cannot find value 'overrides' in this scope, verified. What that does not cover, stated rather than implied: the check could still be deleted from as_categories deliberately. The accidental reordering -- the way this actually happened -- is what is now unrepresentable.

-

The test therefore asserts the hazard rather than the ordering: that the fixture really names one column twice, that the preflight's 41 really disagrees with what forcing would build, and that as_categories itself refuses. A test asserting only the error message would pass on either ordering, since the message is identical -- the whole defect was in work done, not in the result.

-

#the_preflight_counts_the_missing_category_a_forced_column_materialises

-

improvise-x1ns, found by ChatGPT Codex reviewing PR #90 and live on main at ce895ff. encoded_width computed has_missing and used it in the numeric arm only:

-
true  => 1 + usize::from(has_missing),
-false => present.iter().collect::<HashSet<_>>().len().max(1),
-

build_categorical maps every missing cell to MISSING_CAT and includes it in distinct(&mapped), so a categorical column with N distinct present values and at least one absent cell materialises N+1 one-hots. The preflight promised N.

-

It under-reports by exactly one per such column, and it under-reports in the direction that costs: the preflight runs before preprocess, the exact check on feature_names.len() runs after, so the exact check bounds the error message and not the allocation — which is the whole reason the preflight exists. The previous wave's review recorded this shape as "read, not injected" and "bounded by the exact check"; the second half is false and is now corrected in context/code/gp.md §14.

-

Fails before the fix with

-
the preflight under-reported: it promised 5 features and `preprocess`
-materialised 6 — ["Year=(missing)", "Year=2020", "Year=2021", "Year=2022",
-"Year=2023", "Cost"]
-

The fixture is Codex's suggested case, and the reason it is the interesting one: a forced numeric category with a missing coordinate. Forcing is what sends a numeric column down the categorical branch, so only a forced column reaches the arm that was wrong, and only a missing cell makes the arms disagree.

-

The test does not assert a number. It asks preprocess what it actually built and requires encoded_width >= data.feature_names.len(), so it cannot pass by agreeing with a stale constant, and it keeps holding if the encoding widens for some other reason. Three preconditions are asserted first, because each would make it vacuous: that exactly one record lacks the coordinate, that the present cells really are numeric (otherwise the column would be categorical by inference and forcing would prove nothing), and that a Year=(missing) one-hot is really among the materialised features.

-

The .max(1) is deliberately outside the addition. A column whose cells are entirely missing has no present values, and preprocess skips it with a warning rather than encoding it; keeping the clamp on the sum leaves that case counted as 1 exactly as before, so the fix moves only the case it names.

-

#a_split_on_a_numeric_category_read_as_a_number_cannot_be_stored

-

The evidence improvise-e8no is declined on, and the only coverage Artefact::NumericCategory has ever had. It was one of three families with no test, not the only one: MissingFlag and DatePart are covered by the_missing_flag_and_the_date_sub_features_are_refused_too, and ValueOfAMeasure and AbsentCoordinate still have none — filed, not fixed here.

-

improvise-e8no observes that forcing a document category to Forced::Categorical (improvise-zv8t) widens the encoding, so a high-cardinality numeric category now exceeds MAX_FEATURES where inference used to read it as one numeric column and the fit succeeded. The decline turns on what that success was worth: a tree that splits on a numeric column over a category column produces a formula fitted_formula refuses, because a bare reference to the category's name would resolve to a _Measure item of the same name (improvise-8xhy). So the pre-zv8t success was either a fit that never touched the column — for which marking it a Label gives a fit over the same remaining features, within budget — or one that could not be stored. Note what this does not establish: an ordinal encoding is spellable as a weighted indicator sum, so it is declined on semantics and formula size rather than on spellability (context/code/gp.md).

-

The test asks preprocess with Forced::Numeric, which is the pre-zv8t reading reproduced deliberately rather than described, and then asks Features::of and fitted_formula. It does not use the artefacts() helper: that helper fabricates a Spelling, which is the shape gotcha 37(d) warns about — it would assert that the emitter refuses what the test told it to refuse. Here the document and the encoder decide.

-

Injected fault: change spell()'s FeatureRole::Numeric { .. } arm from Spelling::Artefact(Artefact::NumericCategory) to Spelling::Measure(column.clone()) — the "fix" someone would reach for on reading improvise-e8no, and the one that silently reopens improvise-8xhy. Fails with

-
assertion `left == right` failed: a numeric column over a category column is unspellable
-  left: Measure("Year")
- right: Artefact(NumericCategory)
-

#an_ordinal_split_on_a_category_is_spellable_as_an_indicator_sum

-

Not a bug fix: this pins a grammar fact a prose argument rests on, after that argument got it wrong. improvise-e8no's decline was first written claiming that one-hot is the only encoding of a category whose splits the formula language can spell, and therefore that ordinal encoding was ruled out by spellability. An adversarial review disproved it by running parse_formula instead of reading the rule that looked relevant: is_expr is a primary, so it is an Expr yielding 1/0, and cmp_op's operands are full exprs — so IF(2020*(Year IS |2020|) + 2021*(Year IS |2021|) < 2020.5, 1, 2) parses.

-

The decline survives on other grounds (a category's items carry insertion order, not magnitude; and the emitted formula still spells one IS per item, so the width is not saved) — but the stated reason was false, and a decline is the one artefact nothing checks. This test is the check: if the grammar ever narrows so that an indicator sum stops parsing, the paragraph that says it does becomes discoverable rather than quietly wrong.

-

The negative arm is the incidental fact that explains the emitter: Year IS 2020 does not parse, because filter_value is string | pipe_quoted | bare_ident and a bare number is none of them. That is why formula_text pipe-quotes a numeric item name through quote_name, and asserting it here keeps the positive arm from passing for the trivial reason that everything parses.

-

#the_preflight_counts_the_parts_a_date_measure_expands_into

-

improvise-x1ns, second instance — found by ChatGPT Codex on PR #97, live at f128087, i.e. in the very change that fixed the first one.

-

preprocess infers Numeric → Date → Categorical (data.rs:406), and encoded_width had no date arm: numeric-or-categorical only. A date string does not parse as f64, so a measure column holding dates took the categorical arm and was counted by its distinct strings. build_date emits epoch_day/year/month/day/dow — seven parts once any cell has a time (data.rs:326) — so a column with one distinct timestamp promised 1 and materialised 5.

-

Fails before the fix, on the plain-date case:

-
a plain date: the preflight under-reported: it promised 4 features and
-`preprocess` materialised 8 — ["Region=East", "Region=North", "Region=South",
-"When.epoch_day", "When.year", "When.month", "When.day", "When.dow"]
-

The fixture carries a time case because of the rival fix, not because of the bug. Two implementations repair the plain-date failure: "a date column is 5 features", and "5, or 7 when any present cell carries a time". They differ only on a column containing a time, so the second sub-case is what separates them. Injected, date_parts hard-coded to 5:

-
a date with a time: the preflight under-reported: it promised 8 features and
-`preprocess` materialised 10 — [… "When.hour", "When.minute"]
-

Note the plain-date sub-case passes under that injection — so without the time fixture this test would have been satisfied by either reading, and would have pinned the bug without pinning the rule.

-

Both arms compare against data.feature_names.len() rather than against a width computed a second way, so the test cannot pass by agreeing with a stale constant or by reimplementing the thing under test. Two preconditions guard vacuity: every When cell really is the same string (so the categorical count really would be 1), and the fixture's timestamp really does or does not carry a time, asked of parse_datetime rather than of the literal's shape.

-

The column must be a measure — as_categories forces every document category to Forced::Categorical, so a category of dates never reaches the date branch at all. That is gotcha 37(d)'s "the date decline is narrower than it read": this is the genuine date measure.

-
-
- - diff --git a/site/docs/test-evidence/protocol.html b/site/docs/test-evidence/protocol.html deleted file mode 100644 index 50f26a22..00000000 --- a/site/docs/test-evidence/protocol.html +++ /dev/null @@ -1,190 +0,0 @@ - - - - - -improvise-protocol — improvise docs - - - -
- -
-

Source: context/test-evidence/protocol.md

On this page

Test evidence — improvise-protocol

-

What each test's bug was, what fault it can see, and why it can fail. Keyed by exact test name (design-principles §7). A renamed test orphans its entry; python3 scripts/check-comment-runs.py --check-evidence catches that.

-

Tests whose name already states the whole property are not listed here. An entry earns its place by carrying something the assertion cannot: the bug, the injected fault, or the reason a negative arm exists.

-
-

#src/render.rs — the fold

-

#cell_updates_outside_the_cache_are_dropped

-

A projection naming a cell outside the current viewport must be dropped, not panic: frames and cell updates race on the wire, and a client that crashes on a stale index is a client that crashes. This is the totality half of the WidgetCache contract, stated as an example; the ordering half is a_cell_update_that_overtakes_its_labels_is_lost.

-

#a_cell_update_that_overtakes_its_labels_is_lost

-

The fold is not commutative, and a client must not be written as though it were. The frames frame the cells, so a Cells update that overtakes the ColFrame/RowFrame naming its positions is dropped. Reordering the identical three updates gives a different cache, and the assert_ne! is the load-bearing line — without it, a fold where order did not matter would pass.

-

#a_compacted_seed_folds_to_the_same_cache_as_the_whole_history

-

Bootstrap is not a second code path, but a server does not replay history to a late client either — it sends the state it holds now. So the property is compaction: folding the seed lands exactly where folding the whole history landed, including the history's relabels and overwrites.

-

The history is built to be worth compacting — overwritten, relabelled, populated and grown, with the extent moving twice so only the last survives — and seed.len() < history.len() is asserted, because a "seed" that is not smaller than the history is not a compaction and the equality would be vacuous.

-

Note the general rule this is an instance of: asserting fold(prefix) + fold(rest) == fold(whole) is vacuous, being the definition of a left fold (doctrine(fold_tests_must_test_order_not_the_axiom)).

-

#a_snapshot_seeds_every_widget_from_its_compacted_state

-

The same property across the envelope, with two subscribers each folding only what is addressed to it — the dispatch a client does for real.

-

#a_totals_update_that_overtakes_its_columns_is_cut_to_fit

-

The totals strip is framed by the columns on the same terms the cells are: a strip that overtakes the ColFrame naming its positions is cut down, because a renderer zips it with the columns and a length mismatch indexes off the end.

-

#an_empty_totals_strip_is_not_padded_into_blanks

-

An empty strip says "this grid has no totals" — records mode, or an empty grid — and must not be padded into a row of blanks by the re-fit. Padding it would draw an empty total row under a records-mode grid that has none, which is the one thing the emptiness is for. This is the asymmetry that separates totals from cells and col_widths, which are always padded.

-

#a_recategorized_axis_blanks_the_totals_too

-

The totals are contents, not frame: a reframing blanks them for the same reason it blanks the cells. A stale sum under fresh cells is the one number on the screen a user would take at face value. The length assertion is the other half — the frame survives, the numbers do not.

-

Renamed by improvise-n89z, which deleted GridUpdate::Invalidated. The test used to reach the blanking through that arm; it now reaches it through a cats change, which is the thing that really produces it. Proved discriminating by pinning recategorized to false.

-

#a_relabelled_row_is_blanked_and_the_frame_survives

-

The other half of the same deletion. invalidation_blanks_contents_but_keeps_ the_frame asserted what the retired arm did; this asserts what invalidate_relabelled does, and it is strictly stronger, because the old arm blanked everything and so could not tell "blanks what it reframes" from "blanks the lot". Row 1's label does not change and row 1 keeps its value. Proved discriminating by weakening the stale filter from || to &&.

-

#the_page_bar_outlives_a_reshape

-

page_coords is framed by nothing: it names categories, not positions. So it survives a reshape that drops every row and column. Without this, "re-fit everything on a frame update" would look correct.

-

#the_first_rendered_entry_always_shows_its_level

-

Bug (improvise-jjx), moved here with the rule (improvise-anr9): header suppression compared against the entry at idx - 1 even when that entry was scrolled off-screen, so scrolling blanked the group label at the top of the viewport. The first rendered entry always shows its full tuple.

-

The fixture is two-level so the level-0 prefix of index 1 genuinely matches index 0's — a single-level frame could not express the case.

-

#a_repeated_prefix_below_the_first_rendered_entry_is_suppressed

-

The negative arm of the above, and it has to stay: without it, "always show every level" would pass the_first_rendered_entry_always_shows_its_level and the multi-level header would repeat its outer label on every row.

-

#a_data_index_skips_group_headers_and_still_names_its_group

-

Group headers do not consume a data index, and a data index resolves to the group whose header precedes it — which is what makes a column group span drawable by a client that holds no GridLayout. The collapse state travelling with the group is the third assertion, and it is why FrameGroup::collapsed is on the wire rather than re-derived.

-

#shared_ancestry_widens_as_the_level_rises

-

The underline rule: at the leaf level only the selected column, at a higher level every column under the same ancestor. Both levels are checked because a version comparing whole tuples would pass the leaf arm and light only one column at the outer level, and a version comparing only the outermost label would pass the outer arm and light a sibling at the leaf.

-

#body_from_starts_at_the_item_not_at_the_header_above_it

-

A window opening mid-group must be able to name its group without painting that group's header as a row inside the window. The second assertion — body_from(0) is everything, headers included — is what stops the fix being "drop all group headers".

-

#header_widths_are_per_level_and_measured_in_display_cells

-

The one width rule (improvise-1125): per level, the widest label plus a gap, clamped. Measured in display cells, so 日本 counts four and not two — a chars().count() would make level 1 two cells wide, clamp it to MIN_ROW_HEADER_W, and draw the header short. Both clamps are exercised ("ab" is under the floor, "x" * 100 over the ceiling), because a rule whose clamps are never reached is a rule nobody has tested.

-

#a_column_without_its_width_yet_is_measured_in_display_cells

-

Raised by Copilot on PR #23. The fallback width a column is drawn at between its ColFrame and its ColWidths must be in the same unit the authoritative widths are in. chars().count() would draw a wide label half as wide as it needs. The fixture is deliberately a two-scalar / four-cell string, which is the smallest input that separates the two counts.

-

#an_axis_with_no_categories_still_has_one_level

-

An axis carrying no category still draws one header line, and its item tuples are empty — reading a level off one must answer "" rather than panic. This is the shape a view with nothing on Row produces, so it is a live layout and not a degenerate fixture.

-

#a_relabelled_column_does_not_keep_the_old_columns_value

-

A column dropped from the middle used to slide the cells to its right one place left, because fit_to_labels truncates by position: [A,B,C] shrinking to [A,C] left the column now labelled C showing B's value. Nothing on the wire ever forced a producer to declare it first, so apply blanks what it reframes.

-

#a_label_change_blanks_only_the_positions_it_reframes

-

The same rule on the row axis, and the case a bare label shrink gets right by accident: only the relabelled positions are blanked, so an unchanged row keeps what the server already sent for it.

-

#renaming_a_row_category_invalidates_every_cell_under_it

-

Renaming the category re-identifies every cell on the axis, even where every label is byte-identical: a coordinate is cats zipped with the item tuple, so Region = [North] and Department = [North] are different cells wearing the same name.

-

Frame-only, and that word is the test. No Cells and no Totals follow the frame update, because the producer's follow-up is exactly what would hide the bug — apply has to be right with nothing behind it, the same shape as a_cell_update_that_overtakes_its_labels_is_lost.

-

The id assertion is the sharp half and is separate from the text on purpose. A stale CellId::Record under a re-identified coordinate is an editable cell whose write addresses the record the old coordinate named — silent, invisible in any rendering, and the same family of defect as gotcha 18's drill snapshot.

-

The precondition assertion (the items are identical) is load-bearing: without it the test is just the ordinary relabelling case, and cats proves nothing.

-

#renaming_a_column_category_invalidates_every_cell_under_it

-

The column half of the same rule. Here the per-position arm already blanks each total as its column goes stale, so this pins the cells and the ids; the row arm above is the one that needed the totals handled separately.

-

#an_unchanged_category_leaves_the_cells_that_still_fit

-

The negative arm, and it is load-bearing: without it, "blank everything on every frame update" would pass both renaming tests above, and the fold would resend the whole grid after a group collapse.

-

#a_relabelled_column_drops_its_total

-

improvise-hkt6. invalidate_relabelled blanked the cells under a renamed column and left its total standing, so a client that heard ColFrame without a Totals behind it drew the old column's sum under the new column's blanks.

-

Unreachable from grid_updates, which always sends the changed totals in the same batch — which is the point: the rule is that apply does not trust a producer to remember, and the only way to test it is to send the update the producer never sends alone.

-

Three columns and a rename in the middle, so "blanked the right one" is separable from "blanked all of them".

-

#a_relabelled_row_leaves_the_totals_alone

-

The row half is the negative arm and has to stay: a total spans every row, so no single row frames it. Without this, "blank the totals on any relabel" would pass a_relabelled_column_drops_its_total.

-

#col_widths_always_span_the_column_labels

-

col_widths is framed by the columns: a renderer zips the two, so a cache where they disagree in length is one that indexes off the end. ColWidths is its own update and may be a frame behind the frames.

-

#a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by

-

The same invariant seen from the other arm. ColWidths used to be assigned verbatim, so a widths update that disagreed with the frame put the cache straight back into the state col_widths_always_span_the_column_labels exists to forbid — the frame arm was fitted and the widths arm was not.

-

A short update is the ordinary case: the server resized a column the client has not been told about yet. A long one means the widths overtook the ColFrame that frames them, and the fold drops what overtakes its frame — the same rule Cells is already held to. Both directions are asserted, because fitting only one of them is exactly the bug.

-

#item_updates_touch_only_the_named_category

-

Plus the dropped-update arm at the end: an Items update for a category the cache has never heard of is dropped, not inserted — the totality rule again, on a different widget.

-

#an_axes_update_moves_several_categories_at_once

-

A transpose moves two axes at once, which is why Axes takes a list rather than a single pair. The third assertion — membership is undisturbed — is what stops the fix being "resend the whole category list".

-

#every_projection_round_trips_through_json

-

One populated value per CellDisplay shape: numeric decides the alignment and error the styling, so a round trip that only ever carries them at their default proves nothing about either (doctrine(populate_before_roundtrip)).

-

#a_populated_snapshot_round_trips_through_json

-

The Snapshot is the only thing on the wire that is not an event, and the only point at which ViewState is serialized (improvise-q08) — so it is the one message whose round trip nothing else covers. Populated on both halves: a default ViewState beside an empty seed would round-trip through almost no code.

-
-

#src/basis.rs

-

#a_client_that_has_folded_nothing_cannot_claim_a_basis

-

The floor. A client that has been told nothing cannot compose a basis, so it cannot compose a write. Basis has no Default and no public constructor, so BasisCache::claim is the only door — which is what this asserts is shut before the server has published anything.

-

#an_unknown_record_is_omitted_rather_than_guessed

-

A record the client has never been told about is left out, not invented. Defaulting it to zero would be a claim the client cannot support, and the server would read it as "untouched" — the exact forged-basis case Rejection::RecordUnstated exists to catch.

-

#a_claim_carries_the_counter_for_each_record_it_names

-

The second assertion is the one that earns its place: a claim naming one record must not carry another's counter. A claim that returned the whole cache would pass the first assertion and fail this.

-

#the_published_record_list_is_stable_under_traversal_order

-

The published list is deduplicated and ordered, so a client is not sent a fresh basis every time a row moves on screen. Two maps built in opposite insertion order must compare equal — which is the property the BTreeMap parameter buys and a Vec would not.

-
-

#src/frame.rs

-

#the_retired_spellings_do_not_parse

-

The names the deleted envelopes used, which must not resurrect as serde aliases: an envelope accepting two spellings of one variant is two homes again, wearing one type.

-

Not a hypothetical rename — Bootstrap and Command are exactly what the worker pair (improvise-wasm-client::wire) spelled these two payloads before improvise-xks6. ClientFrame::Command joined the list with improvise-bpq2, and it is the sharper of the two: the singleton spelling was this crate's own until that commit, so an alias would be a revival rather than a resurrection, and the payload shapes differ only by a pair of brackets — {"Command":{...}} against {"Commands":[{...}]} — which is exactly the difference a permissive alias would paper over.

-

The injected-fault discipline is the part to keep (doctrine(check_the_checker)): the mutated input is asserted well-formed JSON first, and the failure is asserted to be about the name — serde's unknown variant — not merely that an error occurred. A rewrite that also breaks the JSON makes this pass against an enum that really does have the variant.

-
-

#src/reduce.rs

-

#a_model_command_leaves_the_view_slice_untouched

-

A model command is the document's business, not this client's. Folding one into the view slice would be a client writing state it does not own — the document's answer comes back as projections. The cursor is set to a non-default value first, so "the whole state is still default" cannot stand in for "the state did not move".

-

#folding_the_same_stream_twice_gives_the_same_state

-

Both ends fold the same commands, so the same stream must produce the same state — otherwise the server's mirror drifts from the client it is mirroring (improvise-m91). A Projection is included in the stream, to pin that it passes through reduce_view untouched rather than being a case it handles.

-

#every_command_round_trips_through_json

-

One populated value per payload family (doctrine(populate_before_roundtrip)), not per variant: the exhaustive walk over the 26 ModelCommand variants is every_model_command in improvise-command, where the effect each one names can also be checked.

-

The modelled() helper composes its basis through a seeded BasisCache rather than building a Basis literal — not because this test is about the basis, but because that is the only constructor there is, and a test that needed a second one would be evidence the door had been left open.

-

#cursor_commands_land_in_the_active_views_entry

-

The second assertion is the point: the view we switched away from never gained an entry. An implementation writing a single cursor and copying it per view would pass the first assertion.

-

#switching_views_restores_each_cursor

-

The point of keying the cursor map: switching back restores where you were, and the client can do that without a Workbook.

-

#help_pages_stop_at_both_ends_and_set_clamps

-

Both bounds and the clamp, because going back from page 0 is the one that underflows if written as help_page - 1, and Set out of range is the one that panics if written as an index.

-

It used to be named ..._wrap_in_both_directions_... and asserted wrapping, which was a second derivation of the effects it came from and disagreed with them: effect::HelpPageNext clamps at the last page and HelpPagePrev saturates at zero, while this arm wrapped modulo HELP_PAGE_COUNT. Nothing compared the two, because no client had ever sent Command::HelpPage — the help overlay's h/l were still upstream keys. improvise-e24v made them a client's, and walking_off_the_end_of_the_help_pages_agrees_with_the_host failed on the first press past the end (client 0, host 4). The wire was changed to match the effect rather than the other way round: the effect is what ships in the TUI, and changing that is a product decision nobody asked for. Whether wrapping is wanted on both ends is filed separately.

-
-

#src/lib.rs

-

#the_wire_types_need_nothing_but_core_and_serde

-

improvise-cqi AC 2: this crate crosses a process boundary, so it must not drag a terminal backend with it. A cargo tree check would be external; this is the part the compiler can hold — the types here resolve with only improvise-core and serde in scope, and adding a ratatui or crossterm dependency would have to be written into Cargo.toml deliberately rather than arriving through a use. The CI wasm job is the other half: a crate pulling in crossterm would stop building for wasm32.

-
-

#src/viewmodel.rs

-

#expanding_an_absent_category_is_harmless

-

An expanded name the cache does not carry must not shift the flattening: expanded_cats is per-client and survives a document swap, so it routinely names categories that are gone. A flattening that indexed by expanded-name would go off the end here.

-

#only_quit_advertises_nothing

-

improvise-2hi, carried over from only_quit_has_no_hint when the hints moved out of AppMode (improvise-k099). The old hint_text had a _ => "" arm, so the modes without hints were invisible; ExportPrompt was blank too (improvise-1c9), and the status bar went empty at the one moment the user has to be told what to type. Only Quit may say nothing — it is never rendered.

-

What it asserts now is one step stronger, because hint_line is derived: a mode goes quiet not by someone forgetting a string but by every command it advertises becoming unbound, which every_advertised_hint_names_a_binding_that_exists catches first.

-

#every_advertised_hint_names_a_binding_that_exists

-

The replacement for every_hint_key_resolves_in_its_mode (improvise-2hi), which is deleted, and the deletion is the point (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).

-

That test existed because AppMode::hint_text was a hand-written string promising what the keys do, with nothing tying it to the keymaps. It walked every Key:verb token in every hint through a 60-line tokenizer that had to know the hint dialect — named keys, Ctrl-<c>, F/C/V alternatives, runs like hjkl, the em-dash prose tail, CommandMode's ex-commands — and panicked on a token it could not classify, because a token it skipped was exactly the one that would go unchecked.

-

None of that is needed now. A hint names a command, and its keys are looked up in Keymaps::default_keymaps(), so "the hint names a key that resolves" is true by construction and cannot be otherwise. What remains checkable, and is what this test checks, is the other direction: a mode advertising a command that nothing binds. That was never checkable before.

-

Note what neither version reaches, and gotcha 14 stated for the old one: that the caption is accurate. i:edit where i enters records mode still passes. The caption is English beside a command name, and only a reader can judge it.

-

#the_retired_invalidated_spelling_does_not_parse

-

improvise-n89z deleted GridUpdate::Invalidated, and the one way it can come back without anyone noticing is as a serde alias — a wire spelling that parses and that nothing produces, which is the state the deletion was for.

-

It keeps the discipline the_retired_spellings_do_not_parse earned in frame.rs: assert the mutated input is still well-formed first (a ColWidths projection of the same shape parses), and assert the error kind — serde's unknown variant — rather than merely that an error occurred, because a rewrite that also broke the JSON would make the check pass against an enum that really did have the variant.

-

Proved discriminating by re-adding the variant with an empty apply arm: the retired spelling parses again and the test fails on expect_err.

-

#boundary_tests::the_app_mode_cycle_reaches_every_variant_serde_declares

-

#basis::tests::the_rejection_cycle_reaches_every_variant_serde_declares

-

improvise-5ubs. Both all() functions walk a cyclic after(), and both doc comments claimed a new variant could not be omitted. Answering after() for a new variant is compiler-forced; pointing an existing arm at it is not, so a variant nothing names is never walked. The reviewer of PR #78 measured it: AppMode::all().len() came back 17 against 18 variants, suite green.

-

The independent enumeration is serde's — the Deserialize derive builds the variant-name list from the enum definition and publishes it in its unknown variant ... expected one of ... message. variants.rs reads it back and diffs it against the names the cycle reached, each name taken off the variant's own serialization rather than off Debug.

-

Fault injected 2026-09-21: a Rejection::Probe variant added to the enum, with an after() arm returning DocumentReplaced and nothing pointing at it — precisely the shape the reviewer probed. the_rejection_cycle_reaches_every_variant_serde_declares fails naming ["Probe"]; the other 120 tests in improvise-protocol's lib pass, including the_rejection_cycle half of every basis test. (The workspace does not compile with the probe, because improvise-wasm-client/src/socket.rs matches Rejection exhaustively — which is a different guard and does not catch the omission, since the omitted variant there is one it was forced to name.)

-

#variants::tests::a_type_that_is_not_an_externally_tagged_enum_is_refused

-

#variants::tests::a_message_with_no_expected_clause_is_refused

-

#variants::tests::a_message_that_lists_something_other_than_variants_is_refused

-

#variants::tests::a_list_that_collapses_to_one_name_is_refused

-

#variants::tests::a_well_formed_message_yields_its_names

-

The checker's own checker. A parse of serde's message that silently failed would return an empty name list, and an empty list is a subset of everything — every cycle would pass and the guard would be worse than absent.

-

The first version named one thing and exercised another, found by the PR #92 review. It pointed declared at Basis, a struct, so serde answers invalid type … expected struct Basis — which reaches only the split_once → None branch. The starts_with("unknown variant") and names.len() > 1 refusals were exercised by nothing, and all three panics shared the literal "serde no longer says", so expected = … could not discriminate which had fired. A should_panic whose message matches several panics is a check that cannot say what it caught.

-

The parse is now parse_expected(&str), a seam fed hand-written mutated messages, each refusal carrying its own wording:

-
    -
  • no expected one of clause → serde stopped listing the variants
  • -
  • an unknown field list rather than a variant list → serde stopped saying \unknown variant\``
  • -
  • a list that collapses to one name → the variant list did not parse
  • -
-

plus a happy-path case, so the three refusals are not the only evidence the parser runs at all. a_type_that_is_not_an_externally_tagged_enum_is_refused keeps the end-to-end shape through a real Deserialize.

-

#render::tests::a_frame_that_lost_its_origin_does_not_claim_to_lead_the_axis

-

improvise-1wl0, found by the adversarial review of PR #77. AxisFrame::first carried #[serde(default)], so a frame whose first was dropped in transit deserialized as 0 — which is not an absence but the claim that the frame leads the axis, and exactly what body_from acts on: match self.first + idx == 0. A scrolled browser would then draw the carried group header above its first row, at every scroll position, silently: the mirror image of the improvise-0o6x bug the field was added to fix.

-

The test serializes a frame built for a scrolled window (first: 3), removes the key, and asserts the refusal. Three things make it discriminating rather than a check that something went wrong:

-
    -
  • It asserts the key was there before removing it, so a rename of the field turns this into a failure rather than a vacuous pass.
  • -
  • It asserts the mutated value is still a well-formed AxisFrame minus one field (cats and entries present), so the refusal cannot be about broken JSON — the check-the-checker hole gotcha 42 records from the_retired_spellings_do_not_parse.
  • -
  • It asserts the error kind is missing field, not merely that an error occurred. A first that had been retyped would fail differently and this would notice.
  • -
-

Run against the pre-fix tree 2026-09-21: it fails at the expect_err, printing AxisFrame { .., first: 0, .. } — the forged claim itself. The fix is the removal of one attribute; the whole workspace stays green (1464), which is also the evidence that nothing was relying on the permissive parse.

-

#render::tests::two_runs_under_two_headers_of_one_name_are_two_runs

-

#render::tests::the_two_headers_are_equal_as_values_so_comparing_cat_and_name_cannot_split_them

-

improvise-h0iv. group_runs coalesced adjacent positions by group name, so two runs under two different headers sharing a name read as one and both front ends drew one span across the boundary.

-

The first test is the demonstration, at the level the rule lives: a frame with [Group(Month/Q1), North/Jul, North/Aug, Group(Month/Q1), South/Jul, South/Aug] and nothing ungrouped between the runs. Before the fix it returns [(Q1, 4)]; after, [(Q1, 2), (Q1, 2)]. It asserts the frame really holds two FrameEntry::Group entries first, so a fixture that lost one would fail at the precondition rather than pass with a single run.

-

The second test exists because the ticket proposed the wrong fix and a reader will propose it again. h0iv says FrameGroup carries cat and "comparing the pair would fix it" — but its own reachability case is a single category nested under an outer one, so both headers are (Month, Q1), equal as values. The test asserts that equality directly. What actually separates them is that they are two distinct entries in self.entries, which std::ptr::eq on the borrowed header states exactly.

-

Fault injected 2026-09-21: restoring prev.map(|p| p.name.as_str()) == ... fails the first test with left: [(Some("Q1"), 4)], and fails two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends end to end. The second test passes under both rules, deliberately — it is a statement about the data, not about the comparison.

-

#render::tests::a_client_never_holds_widths_of_a_different_length_from_its_labels

-

improvise-44ad's replacement test, landed while the move it was written for was declined. A renderer zips col_widths with the column labels, so a length mismatch indexes off the end (gotcha 33(c)). The existing guards assert the re-fit on individual arms; this one asserts the invariant across a fold, at every step, over a stream that widens the frame before its widths arrive, lands a cell, catches the widths up, narrows the frame, lands totals, and catches up again.

-

It asserts the first update really widens past the widths held, so a fixture that stopped exercising the gap would fail at the precondition rather than pass.

-

Fault injected 2026-09-21: deleting the width re-fit from fit_to_labels (self.col_widths.truncate(cols); if self.col_widths.len() < cols { replaced by if false {) fails this at step 0 — 3 widths for 5 columns.

-

What that injection also established, and it corrects improvise-44ad. Only four tests in the whole workspace fail: this one and three that already existed (a_col_widths_update_is_fitted_to_the_labels_it_is_framed_by, a_column_without_its_width_yet_is_measured_in_display_cells, col_widths_always_span_the_column_labels). The ticket's premise was that a_client_on_a_socket_is_seeded_and_gets_its_edit_back is gotcha 33(c)'s standing evidence and that rewriting it would destroy the evidence for a rule. It is not, and it does not fail: it asserts a reframe carried widths, which is a statement about the producer's diff. The reason for declining is therefore not the one the ticket gives — see context/code/protocol.md §3.

-

#a_chord_is_recognised_by_its_modifiers_and_not_by_its_label

-

help::compact dropped a chorded key from a status-bar hint by testing whether the formatted label started with "Ctrl-" or "Alt-" — parsing a string it had just formatted. Finding 3 of the adversarial review of PR #50, filed as improvise-thpe and deliberately not fixed there: the reviewer enumerated every format_key_label output and none begins with either, so there was no false positive. The defect was the shape.

-

The regression guard is the signature, as it is for Checked (gotcha 43): compact now takes &[(KeyCode, KeyModifiers)], so the string it used to parse is not in scope to parse. keys_for returns the pairs and Hint::keys carries them; help.rs formats last, through Hint::labels and the one spelling in keymap::key_label. Keymap::labels_for — the "throw the modifiers away into a string" step the ticket names — has no caller left and is deleted, because a consolidation that keeps its old path keeps the defect available (doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)).

-

The rule also got wider, and that is the part a test can state: a chord is any modifier beyond shift, which is total over the bitset, where the string test enumerated two prefixes. Shift alone is not a chord — it is how a key is named rather than how it is held, which S-Tab is the case for.

-

This test cannot fail under the rule it names, and its doc comment says so rather than claiming otherwise. CONTROL|ALT|SHIFT on F(5) spells Ctrl-Alt-F5, which the old prefix test drops too. It pins the rule is_chord states; the discrimination is the injection below and the signature.

-

Injected fault, run rather than argued. A KeyModifiers::SUPER bit and a key_label arm spelling it Super-x were added temporarily. The old filter keeps it — ["T", "Super-x"], two survivors where one is wanted — while compact returns "T". That is the ticket's own reproduction, executed, and it is the only way to see the difference: with KeyModifiers as shipped, every chord's label does begin with Ctrl- or Alt-, so no permanent test can separate the two rules. Both the bit and the probe were removed afterwards.

-

One behaviour that had to be preserved deliberately: keys_for used to dedup labels, and deduping the pairs instead would let two patterns that spell the same key both reach the status bar. It now dedups by label explicitly.

-

The binding dump is byte-identical across this change (4406 lines, diff clean), which is what doctrine(binding_dump_diff) asks of anything touching the keymap: the dump's prefix rows go through Hint, so they had to be checked rather than assumed.

-
-
- - diff --git a/site/docs/test-evidence/tooling.html b/site/docs/test-evidence/tooling.html deleted file mode 100644 index c754f4df..00000000 --- a/site/docs/test-evidence/tooling.html +++ /dev/null @@ -1,66 +0,0 @@ - - - - - -The repository's own gates — improvise docs - - - -
- -
-

Source: context/test-evidence/tooling.md

On this page

Test evidence — the repository's own gates

-

Evidence for the checks in scripts/ and for the tests that hold CI's shape. The arguments behind the checkers themselves are in context/code/tooling.md; this file records what fault each test was watched to catch.

-

Note what is deliberately absent. scripts/check-comment-runs.py --self-test, scripts/kb-test.sh and scripts/assemble-stamp-test.sh are check-the-checker suites whose cases are already injected faults, and none of their case names is a Rust fn or a JS test( the evidence checker could resolve — so their evidence is the case list itself plus context/code/tooling.md, and inventing headings for them here would be headings naming nothing.

-

#"no test in web/tests/ reaches for a built bundle"

-

The js CI job runs node --test web/tests/*.test.js and installs nothing; the browser job runs web/tests/bundle/*.test.js after three wasm-pack builds. The moment a flat web/tests/ file reads web/pkg/, the fast job inherits the slow one's dependency — and the likely repair is to add a build step to it, silently spending the ~4m30s the split exists to avoid. Until improvise-4ktt that constraint was a directory convention and a glob, checked by nothing, and it failed in the direction that looks like ordinary breakage: a confusing ENOENT in CI on a machine where the bundle happens not to be built.

-

Fault injected, 2026-09-21: appended const injected = readFileSync("../pkg/improvise_wasm_client.js"); to web/tests/grid_plan.test.js. The test failed naming that file and pointing at web/tests/bundle/. Reverted; green again.

-

Two things the test does that a simpler one would not. It asserts its own filename is among the files it scans, so it cannot become the one place a violation may hide. And it strips comments before matching, because this file's own header and the test's header both name the directory in prose — a lexical grep over raw text would have had to exempt them, and an exemption is where the next violation goes.

-

What it cannot see, stated because the check itself uses the evasion: a path assembled at runtime ("../" + "p" + "kg") is invisible to a lexical match, and the test's own FORBIDDEN constant is built exactly that way to stay out of its own scan. That is a deliberate, disclosed hole — closing it needs a parser, and the failure it would catch is one somebody has to write on purpose, where this gate exists for the one written by accident.

-

#"the subdirectory that MAY use a bundle is not the one being checked"

-

The companion, and the one that stops the first from passing vacuously. If web/tests/bundle/ were emptied or its files stopped touching the bundle, the first test would still be green while having nothing to be scoped against — "no flat test uses the bundle" is uninteresting if no test anywhere does. This asserts the other half of the split exists and really does what the flat half is forbidden to do.

-

Fault injected, 2026-09-21: moved web/tests/bundle/wasm_surface.test.js out of the tree. The test failed with "web/tests/bundle/ is empty — the split lost its other half" rather than passing. Restored; green again.

-

#"no test in web/tests/ imports a module that reaches the bundle"

-

The companion to the lexical check above, and the reason that one is not enough: import "../app.js" needs the bundle transitively and names nothing forbidden, so the js job breaks loudly but unattributed. app.js and worker.js are the two entry points that load the bundle; shell.js and serial.js do not, and serial.test.js imports one legitimately.

-

Fault injected, 2026-09-21: appended import "../app.js"; to web/tests/grid_plan.test.js. The lexical test stayed green; this one failed naming the file. Reverted.

-

#Note — the checker's own blockers, found by review of PR #83

-

Two gates in scripts/check-comment-runs.py produced a pass while not doing what their prose said. Both were in the mode the same PR rewrote, which is the part worth keeping: writing the doctrine down does not exempt the writer.

-

A leaf name is not an identity. Six leaf names collide in this workspace and two were cited in evidence. Renaming ui::project::tests::a_load_that_fails_is_not_a_rebase — the one its entry describes — left the checker printing all name a test that runs, exit 0, with the heading answered by the ui::host::tests namesake. Now an ambiguous leaf is refused with both candidates named, and the two entries are qualified. Re-verified both ways, 2026-09-21: the refusal fires on the unqualified headings and names exactly those two; with the heading qualified, the same rename is reported as an orphan rather than absorbed.

-

--rust-tests was strictly more permissive and said the same words. It called cargo_test_names with one argument, so the --ignored subtraction never ran, and the success line was byte-identical to the honest path's. Injecting ### eyeball_the_rendered_hints_and_pages orphaned on the default path and passed on the flag's. Now the flag requires --rust-tests-ignored (one alone exits 2) and the success line names its source.

-

A missing evidence directory exited 0. The early return preceded the citation scan, so deleting context/test-evidence/ reported nothing while 170 citations pointed at a directory that was not there. Verified 2026-09-21 by moving the directory aside: exit 1, 170 tests cite context/test-evidence and have no entry there.

-

#Note — the flag that selected the cap away (improvise-ijql)

-

scripts/check-comment-runs.py --check-evidence early-returned in main, so the flagged run never answered the comment-run cap and printed nothing about having skipped it. The reviewer's position when it was first raised was that CI runs all three spellings (ci.yml, the three check-comment-runs.py steps in the test job) so this was documentation only. The counter that carried is that the flagged form is the expensive one — it needs a compiled workspace — which makes it the one a human runs alone; three agents hit it independently in one campaign, and in one case a local gate went green while CI went red on a three-line doc comment.

-

Reproduced before the fix, 2026-09-21, in a temporary tree holding one .rs file with a four-line comment run:

-
$ check-comment-runs.py src --check-evidence --rust-tests … --rust-tests-ignored …
-test-evidence: 0 entries, all name a test that runs …
-EXIT=0
-$ check-comment-runs.py src
-1 comment runs longer than 2 lines:
-  src/bad.rs:1  4 lines
-EXIT=1
-

Two invocations over one tree, opposite verdicts, and the one that exits 0 is the one a human is more likely to run by itself.

-

The guard is _dispatch_self_test, which runs the script as a subprocess — the only level at which this is visible, since every unit in the file was correct and the defect was in main's dispatch. Both arms were demonstrated:

-
injected faultwhat failed
restore the --check-evidence early returnDISPATCH: --check-evidence: exited 0 over a four-line comment run
make the cap always report a violationDISPATCH: bare: refused a clean tree and the same for --check-evidence
-

The second arm is what stops an always-failing cap from satisfying the first, and it is why SELF_TEST_DISPATCH asserts a clean tree passes as well as a dirty one failing. Why the fix is additive rather than a printed disclaimer, and why --self-test/--list-doctests are excluded from the table rather than exempted from the rule: context/code/tooling.md §2, "A flag adds a question".

-

#Note — the stamp gate's discrimination, measured

-

scripts/assemble-stamp-test.sh separates three outcomes rather than two, which is what makes it evidence rather than a smoke test. Measured 2026-09-21 over its nine cases: the real script passes 9; deleting the final test -z "$missing" (the PR #69 injection, which the inline version survived at exit 0) passes 4 and fails 5; a script that does nothing passes 2; one that always fails passes 0.

-

The PR body first cited "4 of 9" for the middle row, from a run taken when the suite had eight cases. A figure is what the next reader checks, so it is recorded here where it can be re-measured rather than only in a message nothing can audit (design-principles §6).

-

#Note — the stamp gate matches literally

-

scripts/assemble-stamp.sh greps with -F. As a regex, the . in improvise_wasm_client.js?v=abc matches any character, so a stamp corrupted in exactly that position reads as present — a pass in the one direction a guard must not fail in. Inherited from the inline version this was extracted from, and found by reading the extraction rather than by anything failing.

-

Fault injected, 2026-09-21: dropping the -F makes improvise_wasm_clientXjs?v=abc satisfy the pattern, and a pattern is matched literally, not as a regex goes from pass to fail.

-

#Note — the doc site's derived checks

-

scripts/build-doc-site.mjs carries two gates that no test names, because the generator is the test: it runs on every assembly and exits non-zero.

-

Faults injected, 2026-09-21, each observed:

-
injectedreported
a new context/code/newarea.mdcontext/code/newarea.md is neither published nor a named omission
context/plan.md moved out of the treecontext/plan.md is listed as omitted but does not exist
a line appended below the generator in web/assemble.shweb/assemble.sh must invoke this generator LAST, naming the offending command
a trailing echo "see scripts/build-doc-site.mjs"the same, once the check asks for an INVOCATION rather than a mention — .includes had accepted it (review, PR #83)
-

scripts/check-ci-graph.py is the third, and it is a script rather than a stanza because its own refusals need checking: adding a zz-new-check: job to ci.yml reports pages-deploy does not need zz-new-check and exits 1, where before the rule was prose and nothing noticed. Its five self-test graphs include the one a naive parse gets wrong — another job's needs: read as the deploy's.

-

The second arm is the one that would not get written first, and it is the point: without it, deleting or renaming an omitted file leaves an entry asserting a reason for a file nobody can find — the same failure as a dead ticket id, which AGENTS.md already calls worse than no id.

-

The first arm was filed as latent and was live when it was written: context/code/kb-tool.md had been unpublished, unargued and unnoticed since it was added.

-

#Duplicate evidence headings — SELF_TEST_DUPLICATES

-

improvise-hsoe. --check-evidence verified that a heading names a test that runs, and a duplicate heading names one just as well, so both copies pass. PR #92 shipped two entries under a_layout_change_moves_the_layout_version_and_no_record, and two more pairs sat in tui.md — one of them asserting that THE_MEMBERSHIP_CORRECT_SPANS was "a live tripwire, do not remove it" some four hundred lines above the entry recording that improvise-gd83 had removed it. A reader meeting the older copy first takes it as current, which is worse than an orphan: an orphan announces itself as broken.

-

The fault is injected by the six cases in SELF_TEST_DUPLICATES, and three of them are the ones that would not get written first. Two different tests sharing a leaf must come back ambiguous and not duplicate, because the wrong diagnosis files the wrong repair. A prose heading must be free to repeat, because it claims no test. And the bare and module-qualified spellings of one test must collide — grouping by the string rather than by the resolved test would have missed exactly the case improvise-rm4s's qualification repair creates, which is the first way this would have come back.

-

The live tree was itself the first injection: before the three merges the check exited 1 naming all three pairs with both line numbers; after them it exits 0. The clean arms (two headings naming two tests, and prose repeated) are what stop an always-refuse from satisfying the rest.

-
-
- - diff --git a/site/docs/test-evidence/tui.html b/site/docs/test-evidence/tui.html deleted file mode 100644 index adfee263..00000000 --- a/site/docs/test-evidence/tui.html +++ /dev/null @@ -1,149 +0,0 @@ - - - - - -The TUI — improvise docs - - - -
- -
-

Source: context/test-evidence/tui.md

On this page

Test evidence — the terminal front end (src/, tests/, examples/)

-

Keyed by exact test name, per design-principles §7. A renamed test orphans its entry; python3 scripts/check-comment-runs.py --check-evidence catches a heading that names no test.

-

A ### heading is a test, and --check-evidence only counts #[test] functions, so it cannot be satisfied by a helper of the same name. Fixtures and helpers that carry an argument get a subordinate #### heading under the tests they serve — unchecked, and deliberately so: there is nothing to orphan, because a renamed helper is found by the compiler.

-
-

#tests/grid_frame_agreement.rs — the two front ends draw the same frame

-

improvise-anr9/s1xw. The divergence this file exists to rule out was real and shipped: GridCache::row_labels was a Vec<String> built by GridLayout::row_label, which slash-joins a multi-level tuple — the exact rendering src/ui/grid.rs::two_row_categories_produce_cross_product_labels asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and the browser drew Food/Alice where the TUI drew two header columns, with no groups and no collapse indicators.

-

The shape of the check is the point. Each front end is driven the way it is really driven — the widget into a ratatui buffer, the client through a Snapshot of the same cache's seed() — and the two are compared on what they draw, not on what they were handed. Comparing the caches would be vacuous: it is one cache.

-

#two_level_rows (helper)

-

The fixture. Both axes multi-level and grouped — the cross product the slash-join used to flatten, on rows and columns, so the frame carries header levels, repeat suppression, group spans and a collapse state in one fixture.

-

Two deliberate asymmetries:

-
    -
  • Carol sits outside the Family group and before it, so collapsing Family still leaves a row. An all-collapsed axis draws no group headers at all, in both front ends and before this change, and a fixture that hit that would be testing that bug rather than the frame.
  • -
  • Month has one grouped quarter and one ungrouped month, so the column runs are not uniform — a col_groups that emitted one span per column, or one span for the whole axis, would pass a uniform fixture.
  • -
-

#tui_row_headers (helper)

-

Slices the painted row header by AxisFrame::header_widths, because that is the same rule the widget lays the sub-columns out with (improvise-1125). Reading the widths a second way here would be the very duplication under test. The body ends at the blank space below the grid, or at the rule above the totals strip — which is not a row of the frame.

-

#tui_header_cells (helper)

-

Cuts one painted header line by the cache's own col_widths past the row header, for the same reason. Labels are right-aligned into width - 1, so a trim recovers them.

-

#the_two_front_ends_draw_the_same_row_header

-

Injected fault, watched fail before keeping: re-introducing the slash-join on either side — labels: vec![rows.item(r).join("/")] in the client's render_model, or rows.item(ri).join("/") in the widget's row header — makes this test fail, naming the row and the two spellings. Both were watched fail.

-

A group header is one label across the whole row header; a data row is one label per level, and the TUI's slicing puts a group label in the first sub-column, so the comparison joins the non-empty text. The group arm compares the full painted string, glyph and name: a branch that checked only the glyph would pass on any group name whatsoever. indicator is the TUI's glyph spelled out locally so the comparison is against what is really painted rather than against a prefix test.

-

The tui.len() > 4 precondition exists because a fixture with no group headers and few data rows compares almost nothing.

-

#the_two_front_ends_draw_the_same_column_header

-

A separate test because the row-header one drives one arm: a col_levels that stopped suppressing repeats, or a col_groups that emitted one span per column, would sit green behind it (doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)).

-

Each header line is cut into columns and compared exactly. A contains would be one-directional: it cannot see a label the browser wrongly blanked (the filter would skip it) and it cannot see one painted under the wrong column.

-

improvise-gd83 is fixed and THE_MEMBERSHIP_CORRECT_SPANS is gone, along with the assert_ne! tripwire that named it. The value it predicted — [(Q1,2), (None,1), (Q1,2), (None,1)] — is now what the test asserts, element for element. Sep is ungrouped, so the run ends before it and resumes under the second measure's own Q1 header; a precondition asserts the fixture still contains an ungrouped column, because without one the test cannot see a leaked group at all.

-

The superseded expectation — Q1 covering all six columns including the two under the ungrouped Sep, because expand_category emitted a header on entering a group and no boundary on leaving one, so the nearest-preceding walk kept answering Q1 — is recorded here because it is what AxisFrame::group_of answered before the fix, and the frame it was derived from still explains the current value. The frame is not one Q1 run — _Measure is the outer level, so the column axis is:

-

col_cats = ["_Measure", "Month"] Group(Q1) [Revenue,Jan] [Revenue,Feb] [Revenue,Sep] Group(Q1) [Cost,Jan] [Cost,Feb] [Cost,Sep]

-

Two Q1 headers, one per measure, and the ungrouped Sep sits inside each run rather than after both. So membership-correct spans are four runs, not two — and they do not coalesce across the second header, because Sep breaks the run between them. Hence [(Some("Q1"), 2), (None, 1), (Some("Q1"), 2), (None, 1)].

-

improvise-8qlr: the TUI arm was group_line.contains("▼ Q1"), which is one-directional and passes while the TUI labels every column. The run rule now lives once, as AxisFrame::group_runs/group_run_heads, and both ends call it; the TUI's group line is compared cell for cell against spans derived from the browser's own runs.

-

Fault injected exactly as that ticket specifies — forcing the TUI to emit the group label on every column (head = col_groups.get(ci), dropping the run-head lookup). Before: cargo test -p improvise stayed green. After: this test fails with ["▼ Q1","▼ Q1","","▼ Q1","▼ Q1",""] against ["▼ Q1","","","▼ Q1","",""].

-

One thing the shared rule does not flatten: the TUI labels its first visible column even mid-run, because a scrolled grid would otherwise draw an unlabelled group line. That is a scrolling concession, kept explicitly, and it is why the comparison is run at col_offset == 0.

-

#neither_front_end_produces_a_slash_joined_label

-

A separate assertion because the agreement tests above would also hold if both ends started slash-joining. This one is the bug's own words.

-

#the_collapse_state_crosses_as_a_fact_not_a_glyph

-

The group's collapse state reaches the browser as a fact, not as a glyph. The TUI spells it ▼/▶ (presentation, gotcha 26); the client gets the boolean and spells it however the DOM wants. Both states are checked, because a collapsed hard-wired to false would pass the first half. The non-empty precondition states the rule it depends on: a collapsed group still owes the user a header to expand again.

-
-

#src/ui/grid.rs

-

#render (helper)

-

The widget takes a GridCache, so the fixture builds one the way the TUI does: through an App, over the whole grid. Going through App rather than hand-rolling a cache is deliberate — these are characterization tests, and a handcrafted cache would test the widget against a fixture rather than against what the projection actually produces.

-

#grouped_model (helper)

-

A grid with a row group, a column group, a page axis and a total row, written against the pre-migration path (improvise-n10) so the move to GridCache is provably behaviour-preserving (doctrine(characterization_survives_the_move)). The fixture and GROUPED_FRAME are byte-identical to the pre-improvise-anr9 versions, which is what makes the comparison mean anything.

-

#the_whole_frame_is_unchanged

-

Asserts the exact buffer text, not a contains, because the facts it does cover — group indicator placement, column alignment, adaptive row-header width, the page bar, the totals strip — are all positional and a contains cannot see any of them.

-

What it does not cover, stated because an overclaim here is worse than a gap: Region is alone on Row and Month alone on Column, so both axes are single-level and this exercises no multi-level header and no repeat suppression — which is the whole subject of improvise-anr9. Widening the fixture would destroy the only byte-exact before/after evidence in that change, so it stays as it is; the multi-level agreement is tests/grid_frame_agreement.rs, which drives both front ends over a two-level, grouped fixture.

-

Re-pinned for improvise-0o6x. GROUPED_FRAME moved by exactly one line: the leading ▼ Co… header now draws above East, and everything below shifts down one. Nothing else in the buffer changed. That the group line, the column headers, the separator, the data rows and the totals strip are byte-identical is the evidence the change is the boundary condition it claims to be and not a reshuffle of the draw loop. It also fails alongside a_group_header_first_on_its_axis_reaches_both_front_ends when body_from's match is forced to false.

-

#two_row_categories_produce_cross_product_labels

-

Names the rendering the protocol must not carry: multi-level row headers show category values separately, never joined with /. It is the assertion tests/grid_frame_agreement.rs's module argument cites as the thing GridLayout::row_label violated.

-
-

#src/ui/import_wizard_ui.rs

-

#a_cut_inside_a_multibyte_character_does_not_panic

-

The bug, found by the PR #12 Copilot review and pre-existing on main: truncate sliced by byte index, so a cut landing inside a multi-byte character panicked. Every string it cuts can carry user input — a CSV column name, a formula being typed, an error message naming a field — so a document with an accented header crashed the TUI rather than eliding a name.

-

"Régions" is 8 bytes for 7 characters: the é occupies bytes 1..3, so max = 3 puts max - 1 = 2 inside it.

-

#no_cut_point_of_a_multibyte_string_panics

-

Not one cut point, every cut point. The interesting indices are the ones inside a character, and which those are depends on the string — so the test walks them rather than picking one and hoping it was the bad one. Each result must also be a prefix of the input, which is what says the boundary search walked down rather than dropping the character it landed in.

-

#ascii_truncation_is_unchanged

-

ASCII is unaffected, which is what lets the characterization fixtures in this file keep asserting exact frames written before the boundary fix.

-

#render (helper)

-

Drives a real ImportWizard and draws it through the projection, which is the whole path the TUI takes. Building the cache from the live wizard rather than by hand is deliberate: a handcrafted fixture would test the widget against the fixture instead of against what wizard_of actually produces, and the mapping is the half of improvise-764 most able to be silently wrong.

-

#every_step_draws_what_it_drew_before

-

Every step the wizard can draw, byte for byte. Written against the pre-migration path (improvise-764), where the widget reached into ImportWizard, ImportPipeline, FieldKind and Workbook directly, so the move onto a projected cache is provably behaviour-preserving (doctrine(characterization_survives_the_move)).

-

Exact text rather than contains: the interesting facts are positional — the column stops in the proposals table, where the hint line sits relative to the panel bottom, how far the date components are indented — and a contains sees none of them.

-

The walk is bounded and stops when advance no longer moves: the wizard's transitions are conditional (improvise-907) and a step that declines to advance must end the walk rather than spin it. NameModel is terminal for advance (gotcha 22) — the Enter there builds, and building is what parks the workbook on Done.

-

#the_formula_step_draws_its_transient_state

-

The transient bits of DefineFormulas: a formula in the list, one being typed, and a message overriding the hint line. Its FORMULA_STEP fixture is the one of the eight pinned frames that improvise-q0vb moved: it used to read something went wrongcel, the cel being the tail of Esc: cancel left behind because the message was written over a longer hint without clearing the rest of the row. The frame changing there was the point of that commit, not a side effect.

-

#a_message_shorter_than_the_hint_leaves_none_of_it

-

improvise-q0vb. The wizard's bottom row has two writers — the step's key hint and the message that overrides it — and set_string writes only as many cells as its string has. A message shorter than the hint therefore left the hint's tail beside it, and the artifact was pinned as correct output in FORMULA_STEP.

-

Asserted on ReviewProposals, whose hint is the longest in the wizard, so the tail it would leave is unmistakable. The precondition asserts the hint really is longer than the message — without it the test would pass on a step where there was nothing to leave behind.

-

#set_bottom_line_clears_every_cell_it_covers_and_no_more

-

The padding is in terminal cells, not characters (Copilot, PR #22). Buffer lays a string out by display width, so a chars().count() pad is wrong in both directions: a wide character counts one and occupies two, so the helper runs past the panel and over its right border; a combining mark counts one and occupies none, so the helper stops short and leaves cells it was supposed to clear.

-

Asserted against a sentinel, which is the correction Copilot made to a first version of this test. Drawing through the widget cannot see the under-padding case at all: the hint that ran first had already filled the row with its own padding, so the cells the combining message failed to reach held spaces rather than anything visible, and a "does the hint survive" assertion passed either way. The first version also looked like it had been proved, because the loop panicked on the wide-character case before ever reaching the combining one — doctrine(an_injected_fault_must_be_the_fault_you_named), from the other end.

-

So the test fills the row with X first and asks the helper directly: every interior cell must be the text or a space, and the two cells standing in for the panel border must still be X. Both faults are then visible as a surviving X — one inside, one outside.

-

#the_array_path_step_draws_its_candidates

-

One of the two steps the flat-array fixture skips. SelectArrayPath is reached by a document with more than one candidate array, which is also the only step that draws pipeline.array_paths.

-

#the_preview_step_draws_an_empty_document

-

WizardStep::Preview is the one step every_step_draws_what_it_drew_before never reaches (improvise-g6s1). Both fixtures there start at ReviewProposals or SelectArrayPath, and nothing advances into Preview — it is only ever an entry step, chosen by ImportWizard::new when the document needs no path selection and parses to zero records.

-

That left it the single arm where a change to wizard_cache could alter what is drawn with nothing failing, which is the same hole the other steps were pinned to close. An empty array is the smallest document that lands there.

-

It also puts on the record a wart the step has today: the summary ends Sample keys: with nothing after the colon, because there are no records to take keys from. Pinned as it is rather than quietly fixed — the point of a characterization fixture is that the next change to it is deliberate.

-
-

#tests/shipped_documents_load.rs

-

#every_shipped_document_loads

-

improvise-odbc. The finding behind it: bank-info.improv sat in the repo root for five months unable to parse — hand-edited through the v2025-04-09 format change rather than round-tripped, so its item lines carried bare names the grammar cannot spell (- JORDAN MARTINEZ) and its data lines named a category Measure the file declares as _Measure. Nothing loaded it, so nothing said so.

-

The grammar itself is covered far better than a fixture can manage, by the generator-driven proptests in improvise-io. What those cannot see is a checked-in document drifting out of the format, because they generate their inputs. This test is aimed at exactly that gap.

-

It is deliberately a directory walk rather than a list of names: a list is a second place to remember, and the file that gets forgotten is the one nobody added to it. The scope is the two directories .gitignore un-ignores — examples/ and web/; everything else matching *.improv is somebody's working file.

-

It matches on the full name, not on Path::extension(): .improv.gz is the same document gzipped and extension() calls it gz, so matching on the extension alone would skip it and put back the very hole this test exists to close.

-
-

#tests/web_demo_model.rs

-

#every_web_demo_cell_is_settable_except_the_column_a_formula_owns

-

improvise-bck. The demo document the standalone page opens must be one a visitor can type into, and the bug is not hypothetical: examples/demo.improv leaves Customer and Date on the None axis, so every cell in it is an aggregate over records the grid does not name, and an aggregate is not settable — correct behaviour, and a demo where the first thing a visitor clicks does nothing.

-

The question is asked exactly the way the browser asks it. ADR 002 says a cell is settable iff its CellId is Record, project::cell_id is the one place that decides, and render.rs::editable in the wasm client is that variant and nothing else (gotcha 42). So the test projects the demo's whole grid through grid_of — the same function the server calls per frame — and asserts on the surrogate, not on the axes. Asserting the axes instead would re-derive the rule and could agree with itself while disagreeing with the client.

-

It was proved discriminating by pointing it at examples/demo.improv, where it names the aggregate at (0,0).

-

improvise-ir7l renamed it, because the old name was false. The demo's third column is Profit = Revenue - Cost, and a formula target is a stored coordinate, so it projected Record and the page offered an editor over it — the test passed while its name was wrong for a third of the landing-page grid, which is exactly the shape this repo keeps finding. The assertion is now two-sided: the two raw columns must be Record and the Profit column must be CellId::Derived, so it fails if either half moves. Proved discriminating in both directions by deleting the derived branch of project::cell_id (Profit reads back as a Record) and, separately, by pointing it at examples/demo.improv again (the aggregate at (0,0) still fails it).

-

#the_web_demo_shows_a_formula_beside_its_inputs

-

The payoff a visitor should see without pressing a key: a formula column beside the two columns that feed it, so an edit visibly moves it. Profit at (0,2) is 18,400 - 6,200, recomputed on load.

-
-

#src/draw.rs

-

#release_kind_converts_like_press

-

The pre-conversion event loop never inspected KeyEventKind, so all kinds (Press/Repeat/Release) were dispatched. The conversion keeps that behaviour: a Release event converts like any other.

-
-

#The panel-migration equivalence tests (improvise-35e)

-

Each panel widget carries a pair: one test saying the inputs are unchanged by the move onto a projected cache, one pinning the drawing of them. The pair is the shape — a cache equivalence alone would not notice a changed renderer, and a rendered fixture alone would not notice the cache carrying different data.

-

#ui::tile_bar::tests::the_cache_carries_what_the_widget_used_to_read_off_the_model

-

#ui::formula_panel::tests::the_cache_carries_what_the_widget_used_to_read_off_the_model

-

Two tests share this leaf name, which is why both headings are qualified: one entry describes the pair, and an unqualified heading would have been satisfied by whichever of them the checker happened to see. The tile bar used to read view.tileable_cats(&model.category_names()) and view.axis_of(cat) off the model directly; the formula panel used to read model.formulas()[i].raw. Each now reads its cache, and a divergence is a rendering change nobody asked for.

-

#the_cache_reproduces_the_summary_the_widget_used_to_build

-

src/ui/view_panel.rs. The axis summary used to be built from workbook.views plus View::categories_on; it is now built from ViewSummary::axes. Same names, same order, same virtual-category filtering.

-

#the_active_view_summary_is_not_empty

-

The fixture must actually exercise the summary, or the equivalence above is two empty strings agreeing.

-
-

#examples/gen-grammar.rs

-

Not a test, but the generator the improvise-io proptests drive. It adds constraints beyond what the grammar requires, to produce realistic, round-trippable output rather than degenerate minima: bare names are drawn from a word pool instead of random letters, pipe_inner is never empty, rest_of_line always produces at least one character, and repetitions (*) produce 1–4 items rather than 0. Each invocation is seeded from the current time plus the PID.

-

#a_status_message_reaches_the_bottom_row_while_a_minibuffer_is_open

-

#the_minibuffer_prompt_is_not_displaced_by_the_message

-

#an_empty_message_never_pushes_a_row_that_fits_past_its_width

-

improvise-mai9. draw_bottom_bar rendered the minibuffer instead of the status line, so a message written while a text-entry mode was open was never drawn — not truncated, not overwritten. A test asserting app.status_msg was set passed in every one of those cases, which is how this survived improvise-6t3's careful effect ordering.

-

Of the ticket's two candidate fixes, the row is shared rather than the callers being made structurally unable to report: giving the status a second row changes frame geometry (improvise-q0vb's cost), and "always leave the mode before reporting" is what CommitSave does by hand and would have to be repeated at every text-entry commit — the thing the ticket calls a workaround.

-

The first version of these tests could not fail for the bug, and that is the entry worth reading. They called the composing helper minibuffer_line directly. The defect was in the wiring — draw_bottom_bar building its own format! and never consulting status_msg — so reverting that one line restored the bug in full with all 1411 tests green. The helper did not even go dead: the test module's use kept it alive while it asserted nothing about the product. Found by review (PR #77), reproduced here before rewriting.

-

So the guard is now Buffer-backed: a real Terminal<TestBackend> renders draw_bottom_bar and the assertion reads the row the terminal received. That is the only assertion that can fail for mai9's actual defect. Fault injected by restoring format!("{}{}", mb.prompt, buf) plus the cursor glyph — both wiring tests fail, the first naming the missing message and printing the row it did compose.

-

The second test exists because a "fix" that dropped to draw_status would satisfy the first on the message alone; it pins that the prompt still leads.

-

The third test is the negative arm, and its first version was unfalsifiable for arithmetic reasons. fill_line computes pad = width - left.len() - right.len(), so with an empty status an unconditional format!(" {msg} ") produces a byte-identical row at any width where the line fits: left plus width - left.len() spaces, either way. The match status_msg.is_empty() is observable only in the band where the prompt fits but left.len() + 4 does not — widths 16..=19 for this fixture, where the unguarded arm returns a row wider than the terminal.

-

That test then failed a second time in a more interesting way: it derived its lower bound by calling minibuffer_line itself, so the injected fault moved the bound with it and the loop skipped the failing band entirely. The bound is now spelled locally. A bound read off the function under test is not a bound — it is the fault grading its own exam.

-

Known and not fixed here: fill_line pads on left.len(), which is bytes, so the right-hand message sits two cells further right than intended whenever the left contains the multi-byte cursor glyph. draw_status has always done this with its own multi-byte content; it is pre-existing and out of scope. It is also what makes the band above four columns wide rather than two.

-

#a_group_header_first_on_its_axis_reaches_both_front_ends

-

improvise-0o6x. AxisFrame::body_from(idx) started at the first item, so at the top of the axis a leading FrameEntry::Group was skipped on the way to the screen — in both front ends. A collapsed group first on its axis therefore had its rows hidden and its header nowhere, so there was no affordance to get them back. The frame carried the header correctly; body_from dropped it.

-

It is a boundary condition, as the ticket suspected, but the boundary is not idx == 0. The windowing rule carries the nearest header ABOVE a scrolled window for group_of and body_from discards it — correctly. At the top of the axis there is no "above", and a frame cannot tell the two apart from idx alone, because the browser's frame is windowed and always passes 0. So AxisFrame gained first, the absolute data index of its first item, and the rule is first + idx == 0. Without it the browser would have drawn an extra header row at every scrolled position that the TUI does not.

-

This test is separate from the_two_front_ends_draw_the_same_row_header because that fixture orders Carol before Family to dodge this case on purpose, and says so. Both collapse states are driven: the uncollapsed arm alone passes against a frame that leads with a header only when it has items under it, which is the shape the windowing bug below produced.

-

The windowing half was found by this test and not predicted by the ticket. A collapsed leading group has NO item to carry its header into the window, so it reaches the frame only through axis_frame's pending queue — and a first version of the fix had GroupEnd clear pending above the window, which deleted exactly that header. GroupEnd is now queued like a header and clears nothing.

-

Fault injected by forcing body_from's match to false: this test fails and the_whole_frame_is_unchanged fails with it.

-

#two_abutting_runs_of_one_group_name_are_two_spans_at_both_ends

-

improvise-h0iv, end to end. The unit demonstration is in context/test-evidence/protocol.md; this is the same rule driven through a real GridLayout, a real project::grid_of and both renderers, because the reachability of the shape was itself in question.

-

two_level_rows has exactly h0iv's shape — Q1 grouping two of three months, cross-producted under two measures — and is saved by the ungrouped Sep between the runs, which is also what makes improvise-gd83's spans come out as [(Q1,2), (None,1), (Q1,2), (None,1)]. abutting_column_groups removes Sep and nothing else, so the two Q1 runs abut. The test asserts that no run is unlabelled before comparing anything: a fixture that grew an ungrouped column back would fail there rather than passing on the easy case.

-

It then compares the TUI's painted group line against the browser's runs exactly, which is the part that makes it an agreement test rather than a second assertion about group_runs.

-

Fault injected 2026-09-21: restoring the name-only comparison in AxisFrame::group_runs fails this with left: [(Some("Q1"), 4)] while the five existing agreement tests all pass — the ungrouped Sep in their fixture is precisely why they cannot see it.

-
-
- - diff --git a/site/docs/tickets.html b/site/docs/tickets.html deleted file mode 100644 index 49398a6a..00000000 --- a/site/docs/tickets.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - -Ticket index — improvise docs - - - -
- -
-

Ticket index

-

The prose on this site cites issue ids like improvise-9c8c. They are this project's own tracker ids, kept in-repo with beads; there is no public issue URL to link to, so this page is what they resolve to. Titles come from .beads/issues.jsonl, a passive export that lags the database — an id it does not carry is listed here without a title and is not linked from the prose.

-

254 ids are cited, of which 254 resolve.

-
IdStatusTitle
improvise-08wbclosed[bug] A refused ClientFrame::Commands keeps the commands before the refusal
improvise-0aiclosedgp-tree: expose a fit/evolve entry point in the library
improvise-0bfclosedInconsistent float-equality tolerance in formula eval
improvise-0hbdeferredMake Sequence bindings transactional at command level
improvise-0o6xclosedA group header first on its axis is never drawn, so a leading collapsed group has no way back
improvise-0x9uclosedA click cannot move the grid selection while an editor is open
improvise-0yq8closedMove ClientState and keys.rs::step into improvise-protocol
improvise-1125closedRow-header width is computed per frame in the widget
improvise-1c9closedExportPrompt mode shows no status-bar hint
improvise-1czclosedexport_csv emits unquoted CSV — commas in values corrupt output
improvise-1eyopenBrowser frontend MVP: end-to-end working demo
improvise-1l9oclosedfurnished_client hand-builds a basis instead of taking the server's
improvise-1wl0closedAxisFrame::first is serde(default), so a frame with no origin claims it leads the axis
improvise-21dclosedEpic: Integrate gp-tree — fit interpretable decision trees as computed measures
improvise-23qdclosedThe Projection::Basis record set is derived twice, and the second copy is the tests' yardstick
improvise-26dmclosedThe fourth length-not-completeness list: cases.len() == 13 in ui/effect.rs
improvise-2avlclosedlayout_version moved outside the effect loop and neither abort arm is tested
improvise-2hiclosedExtract AppMode dispatch methods (mode_label, mode_style, mode_key)
improvise-2lhclosedMigrate name-string comparisons to CategoryKind enum checks
improvise-2oxtclosedThe landing page's casts predate the records columns and show the recorder's shell prompt
improvise-2wmclosedWizardKey effect embeds the import-wizard state machine inside Effect::apply
improvise-2yc2closedDataStore::live_records walks a HashMap -- decide its order before writing the caller
improvise-2zqclosedRecords mode has no inline way to add a category, unlike the + column for measures
improvise-349mclosedformat_md/parse_md is not idempotent when a .improv file has two Category sections with the same name
improvise-35eclosedTemporary adapter: RenderCache::from_app bridge for incremental widget migration
improvise-368hclosed[bug] No client on any transport can ask its host to save
improvise-3gyclosed4.2 Enable GitHub Pages
improvise-3vrclosedvb4 step 1: scaffold ModelState and ViewState types
improvise-3x3uclosedResolve the two unaddressed nullary wire variants before concurrency lands
improvise-3zqclosedRecords-mode Enter/Tab at bottom-right: persist empty record, re-enter editing
improvise-44adclosedColWidths is redundant under the label fold, and the test that would change is gotcha 33(c)'s evidence
improvise-4bwaclosedThree bindings improvise-e24v listed as needing no enrichment do need it
improvise-4dpjclosedThe RecordsNormal-to-Normal arm of settle_normal_mode has zero coverage, and it is live
improvise-4juclosedMake EditOrDrill mode-agnostic; delete EnterEditMode
improvise-4kttclosedThe js job's bundle-freedom is a convention, not a gate
improvise-4pgclosedExtract panel mode keymap template
improvise-4s8closedExtract a gp-tree feature matrix from a View's records
improvise-4ycclosedMalformed numbers in .improv file silently become 0.0
improvise-4zl7closedA fitted tree must route per record and aggregate the results, not route on the aggregate
improvise-56ekclosedSetPanelOpen maps a Panel to a field twice, in two crates
improvise-5j9closedImportPipeline silently skips records missing category values
improvise-5jm5closedModel::evaluate_aggregated takes a list it only tests for emptiness
improvise-5jnaclosedThe browser page does not paint the title bar it is now told about
improvise-5rayclosedcheck-evidence ignores #### headings and every browser-test entry uses them
improvise-5ubsclosedA cyclic-successor all() can silently omit a variant while its doc says it cannot
improvise-60zclosedVirtual views _Records and _Drill should not be persisted to .improv files
improvise-61fclosedAdd invariant test for command registry name mapping
improvise-669bclosedThe harness spec and the page can still disagree about HANDSHAKE_TIMEOUT_MS through a stale assemble
improvise-6c6qclosed[bug] apply_switch relays a refused switch to everyone but the asker
improvise-6kjclosedPest parse errors lack file:line context for users
improvise-6mqclosedIntroduce Storage/VFS abstraction in persistence layer
improvise-6nsdopenA prune-by-write renumbers the grid without moving layout_version -- ADR 001 middle tier
improvise-6osclosedO(n^2) stem deduplication in recompute_formulas
improvise-6sqclosedMerge EditOrDrill and EnterEditAtCursorCmd into EnterEdit{mode}
improvise-6t3closedUnify Commit{Formula,CategoryAdd,ItemAdd} into CommitBuffer(kind)
improvise-711nclosedparse_number_format silently yields 0 decimals for the ,.2f spelling our own docs show
improvise-748fclosedA drill exited with no edits, and view-back, mark the document dirty
improvise-75ldclosedA refused or no-op AddCategory still bumps layout_version and raises dirty
improvise-764closedMigrate import wizard widget and state to consume RenderCache
improvise-78v6closedPR #61 deleted the help-overlay spec and nothing replaced it
improvise-7kvwclosedSetPageSelection renumbers the grid under prune_empty and does not reset the cursor
improvise-7naclosedDecide whether fitted gp-tree models are persisted alongside the formula
improvise-7v62closedChecked does not bind the App it was checked against
improvise-7ybzclosedThe browser category panel has no pointer affordance at all
improvise-7yxclosedClose test gaps flagged by deep review
improvise-864aopenConcurrent merges to main race on one Pages environment and the superseded deploys fail
improvise-8ec3closedimprovise-io's test binary breaches the 2s per-binary rule on some machines, and the pinned figure is not reproducible
improvise-8fyclosedIntroduce simple_effect! macro for single-field setter effects
improvise-8qlrclosedColumn-group run coalescing is derived twice, and the agreement test does not pin the TUI arm
improvise-8shuclosedConstrain the .improv grammar generator's strategy; improvise-io's lib binary is the run's outlier
improvise-8tkclosedZZ / :wq quits even when the save fails, silently discarding the model
improvise-8uj8closedevery_rejection_message_says_the_edit_did_not_land covers five of seven variants
improvise-8uzclosedCursor and scroll are per-client state stored in the shared View
improvise-8xhyclosed[bug] A fit over a categorical feature emits an unevaluable formula
improvise-907closedImport wizard step machine: validate early, allow going back, make the terminal step real
improvise-955nclosed[bug] Host::commands applies a batch partially before rejecting
improvise-9c8cclosedMove the reasoning out of code comments into context/ prose, cap comments at two lines
improvise-9c9oclosedA geometry effect right after a mid-batch SwitchView skips the cursor check entirely
improvise-9cnclosedIntroduce register_cmd! macro to prevent name drift
improvise-9ixclosedSQLite persistence format (alternative save/restore)
improvise-9iyvopenThe route-then-aggregate fold's cost, measured
improvise-9mo9closed[bug] Geometry effects that renumber the grid but never reset the cursor
improvise-9v0jopenview_frame and view_shape are separated by example, not by property
improvise-9x6closedWorker-hosted server bundle (wasm, MessageChannel transport)
improvise-9za9closedDemoting an evidence heading to prose is an unguarded escape hatch
improvise-a2xclosedDerive Serialize/Deserialize for the ViewState field types
improvise-aawclosedReplace CreateAndSwitchView with Binding::Sequence
improvise-ac2zopenA host's none_cats leak into another view's grid: formula columns vanish
improvise-ahdfclosedrepo-map gotchas 36 and 43 still name Sessions::apply as the production door
improvise-anr9closedProject the five grid-frame facts: unblocks H, z, gz and fixes Food/Alice at once
improvise-azhbopenDecide who sets a document's default view now that a switch does not
improvise-bckopenStandalone web MVP: end-to-end offline demo
improvise-bgtfclosedThe landing page is served by the harness and asserted by nothing
improvise-bidqclosedTwo answers to 'is this category virtual', four lines apart in view/types.rs
improvise-bpq2closedClient-side key resolution: what must the projections carry?
improvise-byzclosedrecords_display returns Some("") for out-of-bounds columns instead of None
improvise-c5vclosedImprove records view data entry by making each measure a normal value column (wide format instead of synthetic Value column)
improvise-c6jclosedWizardStep::Done is unreachable from the UI
improvise-cl7yclosedA browser reports "Formula added" for a formula only the host can parse
improvise-cqiclosedExtract improvise-protocol crate (Command, ViewState, reduce_view)
improvise-cqqclosedServer-side projection emission layer + per-client viewport tracking
improvise-cr3closedDOM renderer for browser grid (reads ViewState + render cache)
improvise-ct5closedi on an empty records grid reports 'cannot drill', leaking a pivot-mode concept
improvise-d44sclosedgp_tree::data::preprocess writes to stderr from library code
improvise-djmclosedStandalone main-thread bundle: wasm-client + worker transport
improvise-djnclosedAddCategory effect silently swallows failures
improvise-djooclosedPublish the context/ prose as a documentation site folded into GitHub Pages
improvise-dkqnclosedgp-tree::fit should take one slice of rows, not parallel x/y slices
improvise-dqnclosedCyclePanelFocus does not actually cycle between panels
improvise-drgclosedvb4 step 5: narrow Effect::apply signatures where one-slice
improvise-dz8closedAdd Axis::Filter for drill-fixed coordinates
improvise-e24vclosedSecond tranche of client-side key resolution: ~15 bindings needing no enrichment
improvise-e5pfopenCommand::ChangeMode(Normal) is on the wire and reduce_view folds it without the records resolution
improvise-e8noopenA wide numeric category now refuses a fit that used to ignore it
improvise-edpclosedEstablish ViewModel layer: derived data between RenderCache and widgets
improvise-ehzclosedEnterEditAtCursor effect calls rebuild_layout and builds a CmdContext inside apply
improvise-eteclosedAdd Model/Category getter methods to absorb .unwrap().items/groups chains
improvise-ew0closedvb4 step 3: move UI session fields into ViewState
improvise-fdt6closedA browser cannot add a formula, so FormulaEdit is a room that swallows typing
improvise-fkfgclosedADR-001 b: ModelCommand carries its basis; Applied gains a rejected case
improvise-fujclosedTwo effects read files directly instead of going through Storage
improvise-g0fclosedDecide whether two clients may view different views
improvise-g6s1closedWizardStep::Preview has no characterization fixture
improvise-gcvbclosedThe browser cannot commit a records-mode creating column, though it is now nameable
improvise-gd28openDrill over the wire needs the addressed command form
improvise-gd83closedAxisEntry carries no group-end boundary, so group_of gives an ungrouped item the preceding group
improvise-gdlclosedSetBuffer effect special-cases the "search" buffer name
improvise-gfqclosedImport wizard defers the 'no category accepted' failure to the very last keystroke
improvise-gitclosedReplace SaveAndQuit with Binding::Sequence in keymap
improvise-gv9rclosed[bug] A wire model command never marks the document dirty
improvise-gvnbclosed[bug] A geometry command resets the App cursor but not the session mirrors
improvise-gxiclosedDefine unified reduce_full(&mut App, &Command) -> Vec<Command>
improvise-h04zopenDecide whether UI glyphs move to the protocol, as short_label() did
improvise-h074in_progressTwo edges of App::resolve_view: a worse panic message, and a host-side DeleteView bypass
improvise-h0bclosedEvict wizard and drill_state from ViewState (bootstrap-snapshot hygiene)
improvise-h0ivclosedgroup_runs coalesces by name alone, so two runs under different headers merge
improvise-hkr8closedA click that cannot compose its activation keeps the prefix and drops the payload
improvise-hkt6closedThree small totality gaps found in review
improvise-hmuclosedTAB/Enter on bottom-right of records view should insert record below (TAB→first cell, Enter→same column); o inserts below cursor
improvise-hmwsclosedStorage cannot expose an async seam through Box<dyn Storage>
improvise-hsoeclosedcheck-evidence cannot see a duplicate heading, and three exist
improvise-hwpiclosedThe browser shell fails quietly, and never reads a key
improvise-i2m5closedimprovise-io still carries its own pipe-quoting copy, without the keyword guard
improvise-i34closedOPFS-backed Storage implementation for wasm target
improvise-i5tkclosedNarrow the TUI's GridCache to its viewport -- benchmark first
improvise-igxpclosedThe browser help overlay's rows are asserted as present, never as content
improvise-iioiclosedThe view and formula panels have no pointer affordance while the category panel does
improvise-ijqlclosedcheck-comment-runs.py --check-evidence silently skips the comment-run cap it is named for
improvise-iot6closedrender_csv builds GridLayout::new, so CSV export carries rows the view prunes
improvise-ir7lclosed[bug] A formula cell is offered as editable and silently reverts
improvise-izkclosedgp-tree step 1: emit a fitted Tree as an improvise formula string
improvise-j31xclosedServer hosts several documents: a registry above Sessions
improvise-j7v6closedThe typed-string classification lives in two crates, and its guard test asserts by hand
improvise-jc8closedEvict host-only state from ViewState: flip the transient-keymap Arc
improvise-jjxclosedScrolled grid suppresses first visible row-header label
improvise-jmjzclosedgrid_of evaluates the whole grid three times per frame; compute_col_widths is ~45% of it
improvise-jq8lclosedweb/app.js never calls scrollIntoView, so a selection can sit outside the window
improvise-jytmclosedSplit active_view: document default vs per-client authoritative (improvise-g0f)
improvise-k099closedAppMode::hint_text/label put terminal UI strings on a serde wire enum
improvise-k2i4closedThe two nullary model commands are unaddressed and act on one client's staged state
improvise-k69yclosed[bug] draw_status reads the document's active view, not the client's
improvise-k8hclosedTie MinibufferConfig.buffer_key to the keymap's buffer name
improvise-k8iclosedCSV import silently drops columns when row widths mismatch
improvise-kc7nclosedimprovise-ws-server never checks Origin, so any page a user visits can drive their session
improvise-kdpclosedDuplicated aggregation dispatch block
improvise-kh8closedPhase 4: Landing page (optional)
improvise-kmgsclosedDerive the model half of the Command vocabulary from the EffectKind::Model effects
improvise-kosclosed_Dim and _Index should work as pivot axes outside Records/Drill view; must default to None axis on non-records views including empty models
improvise-kpnropenimprovise-ws-server has no authentication, and the documented sharing path now exposes it
improvise-ksstclosedA gp-tree fit records its seed but not its GA config
improvise-ktv1closedMove the help content into the shared layer so a browser can show help
improvise-l4qxclosed[bug] A text cell is invisible whenever any category is on the None axis
improvise-l8rhclosedThe ambiguous-date heuristic reads American-first, so a European CSV imports with days and months swapped
improvise-laeclosedZZ / :wq with no file path should prompt for a path instead of only erroring
improvise-llttopen[bug] A view-scoped ModelCommand names no view, so it lands on the host's
improvise-lyycclosedcheck-evidence is one-directional: a test citing the evidence file can have no entry
improvise-m91deferred(Stretch) Command log + native undo/replay
improvise-maeclosedTag existing effects as model / view / projection-emitting
improvise-mai9closedA status message written while a minibuffer is open is never drawn
improvise-mcq4openThe frame fingerprint is a 4x regression on the held-key path improvise-748f just optimized
improvise-mzvclosedImport wizard does not pre-check 12-category limit
improvise-n10closedMigrate GridWidget to consume RenderCache + ViewState (not &App)
improvise-n3wtclosedprune_axis_items: keep-slice length invariant is only a debug_assert
improvise-n89zclosedThree Projection mutations have no producer
improvise-ni6kclosedInvalidate the cursor by observing the grid frame, not by a per-effect table
improvise-nrsclosedDuplicated find_item_category helper in eval paths
improvise-nynkclosedadopt_cursor_epoch resets every client's cursor, including clients on a view nothing reshaped
improvise-o1cclosedImport wizard has no way back; Esc discards all field decisions silently
improvise-o31uclosedThe landing page's casts render at a 4.8px font on a phone, and it is now the site root
improvise-oaqclosedRedundant file re-read in ImportJsonHeadless effect
improvise-odbcclosed[bug] bank-info.improv in the repo root does not parse
improvise-ondjclosed[bug] reduce_full/apply_command apply a Command::Model without the basis check
improvise-p1ymclosedProject the page-axis item lists so [ and ] work in a browser
improvise-pcaclosedMigrate panel widgets (category/formula/view/tile_bar) to consume RenderCache
improvise-pczvclosed.improv section headers are a second, unquoted naming rule
improvise-pdvmclosedAddItem/RemoveItem reshape the grid but declare CellData, so layout_version and cursor_epoch disagree
improvise-plcxclosedThe browser harness needs a grouped, multi-level fixture — the demo has neither
improvise-poiaclosedThe records-mode + column has no projected identity
improvise-pubtclosed[bug] display_value_in reads the host's drill edits against a foreign layout
improvise-q08closedimprovise-ws-server binary (tokio + tungstenite session wrapper)
improvise-q0vbclosedPad the import wizard's hint line to the panel width
improvise-qf57closedThe formula-row click test lost its no-editor-opened assertion in the #61 relocation
improvise-qsmpclosedApp::cmd_context's panel-open propagator is untested: C cannot open a closed panel and nothing fails
improvise-r08uclosedIS and WHERE differ on trailing word boundary in formula.pest
improvise-r4loclosedTwo answers to what a typed string means, and they are adjacent
improvise-res9closedDoc site: derive the published set, so an unpublished context/ doc fails the build
improvise-rkeqclosedDecide RenderCache's shape: grid-only wire type vs per-widget sub-caches
improvise-rm4sclosedThe evidence check accepts headings naming tests that never run
improvise-rmlclosedConsolidate Axis display into a method on Axis
improvise-rmvxclosedSix advertised keys do nothing in the browser, and the guard is scoped to one mode
improvise-rucfdeferredRead arbitrary SQLite databases as a data source
improvise-ry89closedcv_regress/cv_classify with k=1 now return plausible numbers instead of NaN
improvise-s13closedDelete mode-entry shim commands; use enter-mode with arg
improvise-s17oclosedreduce_full and apply_command's view-slice arm have no production caller
improvise-seikclosedClient::viewport is kept for a reason web/app.js falsified in the same wave
improvise-shytclosed[bug] Records-mode row order is not stable across processes
improvise-sihrclosedEvery minibuffer mode entered from records mode exits to plain Normal while the layout is still records
improvise-sxsfopencargo test -- --list cannot name the binary, so two crates could share a full test path
improvise-t3uxclosedcv_regress and cv_classify are O(n-squared)
improvise-t6dbclosedgrid_in ignored prune_empty for a non-active view, hiding a staleness bug
improvise-t8sclosedView::axis_of panics on missing category
improvise-taxxclosed[bug] The socket close listener overwrites a precise error with disconnected
improvise-th7mclosedDead F(1) binding: bound to bare 'enter-mode' then immediately rebound
improvise-thpeclosedhelp::compact discriminates a chord by parsing a string it formatted
improvise-tm6openEpic: Standalone static-web deployment via wasm + VFS storage
improvise-tocclosedEffect::changes_mode() is a static flag but ViewBack/ViewForward/StartImportWizard change the mode conditionally
improvise-tom1closedDrill pending edits are invisible to remote clients, and there are now three answers to 'what text is in this cell'
improvise-trd3openHelp pages clamp at both ends; decide whether they should wrap
improvise-tvtclosedRoute mutations through Workbook facade instead of app.workbook.model chains
improvise-ut7pclosedimprovise-gp fit_from_gen0 is a third copy of the GA loop
improvise-vhczclosed[bug] OpfsFs::list disagrees with the other backends on path shape
improvise-vjmsclosedDecide whether a category panel shows the virtual categories
improvise-vvl3closedA headless browser harness: run storage_contract and the shell assertions under Playwright
improvise-vx3closedfit-measure command: fit a tree and add it as a formula
improvise-w3qclosedExtract text-entry keymap template for minibuffer modes
improvise-wd6nclosedA fit may still not split on col.missing or a date sub-feature
improvise-wsawclosedThe browser ignores category_panel_open, so C toggles nothing visible
improvise-x1nsclosedencoded_width undercounts a forced category that has a missing coordinate
improvise-x2cclosedvb4 step 2: move workbook/file_path/dirty into ModelState
improvise-x7cqclosedA kb.pl inconsistency can still reach the published site
improvise-x7swclosedWhich-key hints have no protocol home (the open half of improvise-jb3)
improvise-xf1deferredVerify OpfsFs against Safari — Chromium and Firefox are covered
improvise-xks6closedOne home for the client/server frame envelopes
improvise-xps8closedThe handshake watchdog overwrites a genuine mid-session disconnect eight seconds later
improvise-xwi6closedConcurrent edits: basis check (ADR 001, layer 1 — the correctness floor)
improvise-y4xgclosedCmdContext carries a fourth Panel-to-field mapping, and it is completely unguarded
improvise-ychclosedNull category values become the literal item "null" on import
improvise-ywdclosedWasm compatibility audit and fixes across core/formula/command/io
improvise-z05rclosedCommitExport does not bail on an empty path, and ExportCsv does not abort the batch
improvise-z5egclosedGate improvise-gp in the CI wasm job
improvise-z754closedDocument name, dirty flag and file path have no home a client can read
improvise-zgbclosed.improv formula loader mis-splits category suffix via rfind(" [")
improvise-zkkwclosedThe browser shell is only ever run on Chromium, but improvise-bck's criterion says Firefox too
improvise-zv8tclosedfit_measure never tells preprocess which columns are categories, so a numeric- or date-valued category is unspellable
-
-
- - diff --git a/src/draw.rs b/src/draw.rs index 65a8eb52..af5a86e7 100644 --- a/src/draw.rs +++ b/src/draw.rs @@ -1,5 +1,6 @@ use std::io::{self, Stdout}; use std::path::PathBuf; +use std::sync::OnceLock; use std::time::Duration; use anyhow::Result; @@ -18,7 +19,8 @@ use ratatui::{ use crate::command::key; use crate::protocol::EditorSession; -use crate::ui::app::{App, AppMode, MinibufColor}; +use crate::protocol::face::{AnsiSlot, Colour, Face, Slant, Theme, Weight}; +use crate::ui::app::{App, AppMode}; use crate::ui::category_panel::CategoryContent; use crate::ui::formula_panel::FormulaContent; use crate::ui::grid::GridWidget; @@ -29,7 +31,6 @@ use crate::ui::text::truncate; use crate::ui::tile_bar::TileBar; use crate::ui::view_panel::ViewContent; use crate::ui::which_key::WhichKeyWidget; -use crate::view::AxisColor; use improvise_command::ui::project; use unicode_width::UnicodeWidthStr; @@ -58,7 +59,7 @@ impl<'a> Drop for TuiContext<'a> { pub fn run_tui( workbook: crate::workbook::Workbook, file_path: Option, - import_value: Option, + import: Option, ) -> Result<()> { let mut stdout = io::stdout(); let mut tui_context = TuiContext::enter(&mut stdout)?; @@ -68,8 +69,8 @@ pub fn run_tui( app.term_height = h; } - if let Some(json) = import_value { - app.start_import_wizard(json); + if let Some(pipeline) = import { + app.start_import(pipeline); } else if app.is_empty_model() { app.view_state.mode = AppMode::Help; } @@ -165,24 +166,54 @@ fn convert_modifiers(mods: event::KeyModifiers) -> key::KeyModifiers { // ── Drawing ────────────────────────────────────────────────────────────────── -/// The one place `AxisColor` becomes a terminal color (improvise-rml); the -/// accent itself is decided by `Axis::color`. See `context/code/tui.md`. -pub(crate) fn axis_fg(color: AxisColor) -> Color { - match color { - AxisColor::Row => Color::Green, - AxisColor::Column => Color::Blue, - AxisColor::Page => Color::Magenta, - AxisColor::Filter => Color::Yellow, - AxisColor::Inactive => Color::DarkGray, +/// The one place a face becomes a terminal style (ADR 004): the theme resolves +/// it, and only this last translation is the TUI's. `context/code/tui.md`. +pub(crate) fn face_style(face: Face) -> Style { + static SHIPPED: OnceLock = OnceLock::new(); + let face = SHIPPED.get_or_init(Theme::shipped).resolve(face); + let mut style = Style::default(); + if let Some(fg) = terminal_colour(face.foreground) { + style = style.fg(fg); } + if let Some(bg) = terminal_colour(face.background) { + style = style.bg(bg); + } + let modifiers = [ + (face.weight == Weight::Bold, Modifier::BOLD), + (face.slant == Slant::Italic, Modifier::ITALIC), + (face.underline, Modifier::UNDERLINED), + (face.reverse, Modifier::REVERSED), + ]; + modifiers + .into_iter() + .filter(|(on, _)| *on) + .fold(style, |style, (_, m)| style.add_modifier(m)) } -/// Map the semantic minibuffer accent to a concrete terminal color. -fn minibuf_fg(color: MinibufColor) -> Color { - match color { - MinibufColor::Green => Color::Green, - MinibufColor::Cyan => Color::Cyan, - MinibufColor::Yellow => Color::Yellow, +/// `None` is "state nothing", so the terminal's own colour shows through. +fn terminal_colour(colour: Colour) -> Option { + match colour { + Colour::Default => None, + Colour::Named(slot) => Some(match slot { + AnsiSlot::Black => Color::Black, + AnsiSlot::Red => Color::Red, + AnsiSlot::Green => Color::Green, + AnsiSlot::Yellow => Color::Yellow, + AnsiSlot::Blue => Color::Blue, + AnsiSlot::Magenta => Color::Magenta, + AnsiSlot::Cyan => Color::Cyan, + AnsiSlot::White => Color::Gray, + AnsiSlot::BrightBlack => Color::DarkGray, + AnsiSlot::BrightRed => Color::LightRed, + AnsiSlot::BrightGreen => Color::LightGreen, + AnsiSlot::BrightYellow => Color::LightYellow, + AnsiSlot::BrightBlue => Color::LightBlue, + AnsiSlot::BrightMagenta => Color::LightMagenta, + AnsiSlot::BrightCyan => Color::LightCyan, + AnsiSlot::BrightWhite => Color::White, + }), + Colour::Indexed(n) => Some(Color::Indexed(n)), + Colour::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)), } } @@ -231,30 +262,6 @@ fn draw_popup_frame(f: &mut Frame, popup: Rect, title: &str, border_color: Color inner } -/// Terminal styling for the mode indicator. Exhaustive on purpose, so a new -/// `AppMode` fails the build here (improvise-zx8t) — `context/code/tui.md`. -fn mode_style(mode: &AppMode) -> Style { - let bg = match mode { - AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green, - AppMode::CommandMode { .. } => Color::Yellow, - AppMode::TileSelect => Color::Magenta, - AppMode::Normal - | AppMode::RecordsNormal - | AppMode::FormulaEdit { .. } - | AppMode::FormulaPanel - | AppMode::CategoryPanel - | AppMode::CategoryAdd { .. } - | AppMode::ItemAdd { .. } - | AppMode::ViewPanel - | AppMode::ImportWizard - | AppMode::ExportPrompt { .. } - | AppMode::SavePrompt { .. } - | AppMode::Help - | AppMode::Quit => Color::DarkGray, - }; - Style::default().fg(Color::Black).bg(bg) -} - fn draw(f: &mut Frame, app: &App) { let size = f.area(); @@ -442,12 +449,9 @@ fn draw_bottom_bar(f: &mut Frame, area: Rect, app: &App) { .unwrap_or(""); let text = minibuffer_line(&mb.prompt, buf, &app.view_state.status_msg, area.width); f.render_widget( - Paragraph::new(text).style( - Style::default() - .fg(minibuf_fg(mb.color)) - .bg(Color::Indexed(235)) - .add_modifier(Modifier::BOLD), - ), + Paragraph::new(text).style(face_style( + Face::of_minibuffer(&app.view_state.mode).unwrap_or(Face::ModeTextEntry), + )), area, ); } else { @@ -484,7 +488,7 @@ fn draw_status(f: &mut Frame, area: Rect, app: &App) { let line = fill_line(left, &view_badge, area.width, Keeps::Left); f.render_widget( - Paragraph::new(line).style(mode_style(&app.view_state.mode)), + Paragraph::new(line).style(face_style(Face::of_mode(&app.view_state.mode))), area, ); } @@ -639,6 +643,43 @@ mod bottom_bar_tests { ); } + /// The first cell of the bottom row, where the prompt's first glyph lands. + fn prompt_cell(app: &App) -> ratatui::buffer::Cell { + let mut terminal = Terminal::new(TestBackend::new(40, 1)).expect("test backend"); + terminal + .draw(|f| draw_bottom_bar(f, Rect::new(0, 0, 40, 1), app)) + .expect("draw"); + terminal.backend().buffer().content()[0].clone() + } + + /// ADR 004 clause 8's one declared exception (the user, Q1): the + /// category-add prompt draws as item-add does. `context/test-evidence/tui.md`. + #[test] + fn the_category_add_prompt_draws_as_the_item_add_prompt_does() { + let mut app = App::new(Workbook::new("T"), None); + app.view_state.mode = AppMode::item_add("Region".into()); + let item = prompt_cell(&app); + app.view_state.mode = AppMode::category_add(); + let category = prompt_cell(&app); + assert_eq!( + category.symbol(), + "n", + "the prompt is not where it was looked for" + ); + assert_eq!(item.fg, ratatui::style::Color::Green); + assert_eq!(category.style(), item.style()); + } + + /// The status line wears the mode's face, not the minibuffer's. + #[test] + fn the_status_line_is_drawn_in_the_mode_face() { + let mut app = App::new(Workbook::new("T"), None); + app.view_state.mode = AppMode::TileSelect; + let cell = prompt_cell(&app); + assert_eq!(cell.bg, ratatui::style::Color::Magenta); + assert_eq!(cell.fg, ratatui::style::Color::Black); + } + /// A refusal that keeps the user in a minibuffer stands while they fix it, and /// goes when they leave (improvise-su4u). `context/test-evidence/tui.md`. #[test] @@ -1187,3 +1228,94 @@ mod content_tests { } } } + +#[cfg(test)] +mod face_tests { + use super::*; + use crate::protocol::face::TextEntry; + use crate::protocol::render::WizardFieldKind; + use crate::view::Axis; + + /// `mode_style`'s table as it stood before faces (improvise-zx8t). + fn mode_bg_before_faces(mode: &AppMode) -> Color { + match mode { + AppMode::Editing { .. } | AppMode::RecordsEditing { .. } => Color::Green, + AppMode::CommandMode { .. } => Color::Yellow, + AppMode::TileSelect => Color::Magenta, + AppMode::Normal + | AppMode::RecordsNormal + | AppMode::FormulaEdit { .. } + | AppMode::FormulaPanel + | AppMode::CategoryPanel + | AppMode::CategoryAdd { .. } + | AppMode::ItemAdd { .. } + | AppMode::ViewPanel + | AppMode::ImportWizard + | AppMode::ExportPrompt { .. } + | AppMode::SavePrompt { .. } + | AppMode::Help + | AppMode::Quit => Color::DarkGray, + } + } + + /// ADR 004 clause 8: the shipped theme draws every migrated site exactly as + /// its literal did. `context/test-evidence/tui.md`. + #[test] + fn the_shipped_theme_reproduces_every_migrated_literal() { + let fg = |c| Style::default().fg(c); + let axes = [ + (Axis::Row, Color::Green), + (Axis::Column, Color::Blue), + (Axis::Page, Color::Magenta), + (Axis::Filter, Color::Yellow), + (Axis::None, Color::DarkGray), + ]; + for (axis, color) in axes { + assert_eq!(face_style(Face::Axis(axis)), fg(color), "{axis:?}"); + } + let kinds = [ + (WizardFieldKind::Category, Color::Green), + (WizardFieldKind::Measure, Color::Cyan), + (WizardFieldKind::TimeCategory, Color::Magenta), + (WizardFieldKind::Label, Color::DarkGray), + ]; + for (kind, color) in kinds { + assert_eq!(face_style(Face::WizardField(kind)), fg(color), "{kind:?}"); + } + for mode in AppMode::all() { + let before = fg(Color::Black).bg(mode_bg_before_faces(&mode)); + assert_eq!(face_style(Face::of_mode(&mode)), before, "{mode:?}"); + } + let prompts = [ + (TextEntry::Edit, Color::Green), + (TextEntry::ItemAdd, Color::Green), + (TextEntry::Formula, Color::Cyan), + (TextEntry::Command, Color::Yellow), + (TextEntry::Export, Color::Yellow), + (TextEntry::Save, Color::Yellow), + ]; + for (entry, color) in prompts { + let before = fg(color) + .bg(Color::Indexed(235)) + .add_modifier(Modifier::BOLD); + assert_eq!(face_style(Face::MinibufferOf(entry)), before, "{entry:?}"); + } + } + + #[test] + fn every_face_reaches_the_terminal_with_every_attribute_it_states() { + let resolved = |face| Theme::shipped().resolve(face); + for face in Face::all() { + let r = resolved(face); + let style = face_style(face); + assert_eq!(style.fg, terminal_colour(r.foreground), "{}", face.name()); + assert_eq!(style.bg, terminal_colour(r.background), "{}", face.name()); + assert_eq!( + style.add_modifier.contains(Modifier::BOLD), + r.weight == Weight::Bold, + "{}", + face.name() + ); + } + } +} diff --git a/src/main.rs b/src/main.rs index f2144274..53d8d06f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,8 @@ trait Runnable { fn run(self, model_file: Option) -> Result<()>; } +// Parsed once per process, so a variant's size costs nothing worth boxing for. +#[allow(clippy::large_enum_variant)] #[derive(Subcommand)] #[enum_dispatch(Runnable)] enum Commands { @@ -72,10 +74,16 @@ struct ImportArgs { #[arg(long)] skip: Vec, - /// Extract date component, e.g. "Date:Month" (repeatable) + /// Extract date component, e.g. "Date:Month" (repeatable). The components + /// nest in ONE category named after the column unless --split-date names it #[arg(long)] extract: Vec, + /// Split a date field's components into one flat category each, beside its + /// raw values, rather than one hierarchical category (repeatable) + #[arg(long)] + split_date: Vec, + /// Set category axis, e.g. "Payee:row" (repeatable) #[arg(long)] axis: Vec, @@ -97,6 +105,10 @@ struct ImportArgs { #[arg(long, value_enum, default_value_t = OnDuplicate::Refuse)] on_duplicate: OnDuplicate, + /// With --no-wizard, refuse (and write nothing) if any record is skipped + #[arg(long)] + strict: bool, + /// Save to file instead of opening TUI #[arg(short, long)] output: Option, @@ -144,7 +156,7 @@ impl Runnable for ImportArgs { if self.files.is_empty() { anyhow::bail!("No files specified for import"); } - let import_value = get_import_data(&self.files) + let pipeline = get_import_data(&self.files) .ok_or_else(|| anyhow::anyhow!("Failed to parse import files"))?; let config = ImportConfig { @@ -153,16 +165,18 @@ impl Runnable for ImportArgs { time_fields: self.time, skip_fields: self.skip, extractions: parse_colon_pairs(&self.extract), + split_dates: self.split_date, axes: parse_colon_pairs(&self.axis), formulas: self.formula, name: self.name, on_duplicate: self.on_duplicate, + strict: self.strict, }; if self.no_wizard { - run_headless_import(import_value, &config, self.output, model_file) + run_headless_import(pipeline, &config, self.output, model_file) } else { - run_wizard_import(import_value, &config, model_file) + run_wizard_import(pipeline, &config, model_file) } } } @@ -187,10 +201,12 @@ struct ImportConfig { time_fields: Vec, skip_fields: Vec, extractions: Vec<(String, String)>, + split_dates: Vec, axes: Vec<(String, String)>, formulas: Vec, name: Option, on_duplicate: OnDuplicate, + strict: bool, } fn parse_colon_pairs(args: &[String]) -> Vec<(String, String)> { @@ -202,10 +218,10 @@ fn parse_colon_pairs(args: &[String]) -> Vec<(String, String)> { .collect() } -use import::wizard::DuplicatePolicy; +use import::wizard::{DuplicatePolicy, ImportPipeline}; fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, config: &ImportConfig) { - use import::analyzer::{DateComponent, FieldKind}; + use import::analyzer::{DateComponent, DateShape, FieldKind}; // Override field kinds for p in &mut pipeline.proposals { @@ -238,6 +254,13 @@ fn apply_config_to_pipeline(pipeline: &mut import::wizard::ImportPipeline, confi } } + // The wizard's `s`, headless: which date fields split rather than nest. + for p in &mut pipeline.proposals { + if config.split_dates.contains(&p.field) { + p.date_shape = DateShape::Split; + } + } + // Set formulas pipeline.formulas = config.formulas.clone(); @@ -288,14 +311,43 @@ fn apply_axis_overrides(wb: &mut Workbook, axes: &[(String, String)]) { } } +/// Why `--axis` is refused: the categories the import did build, and a date +/// hint only for a name shaped like one of the split date parts. +fn axis_refusal(cat: &str, built: &[String]) -> String { + let date_part = ["_Year", "_Month", "_Quarter"] + .iter() + .any(|suffix| cat.ends_with(suffix)); + let hint = if date_part { + " A date's parts are one category unless --split-date names it." + } else { + "" + }; + format!( + "--axis names '{cat}', which this import did not build.{hint} Built: {}", + built.join(", ") + ) +} + fn run_headless_import( - import_value: Value, + mut pipeline: ImportPipeline, config: &ImportConfig, output: Option, model_file: Option, ) -> Result<()> { - let mut pipeline = import::wizard::ImportPipeline::new(import_value); apply_config_to_pipeline(&mut pipeline, config); + // A strict skip refuses before the build, so nothing is written (8jk6.6). + if let (true, Some(skipped)) = (config.strict, pipeline.skip_warning()) { + anyhow::bail!("--strict: {skipped}"); + } + if let Some(field) = config.split_dates.iter().find(|f| { + !pipeline.proposals.iter().any(|p| { + &p.field == *f + && p.kind == import::analyzer::FieldKind::TimeCategory + && p.date_format.is_some() + }) + }) { + anyhow::bail!("--split-date '{field}' is not a date column of this import"); + } if pipeline.on_duplicate == DuplicatePolicy::Refuse { let collisions = pipeline.collisions(); if !collisions.is_empty() { @@ -306,6 +358,16 @@ fn run_headless_import( for warning in &warnings { eprintln!("warning: {warning}"); } + if let Some((cat, _)) = config + .axes + .iter() + .find(|(cat, _)| wb.model.category(cat).is_none()) + { + anyhow::bail!(axis_refusal( + cat, + &wb.model.categories.keys().cloned().collect::>() + )); + } apply_axis_overrides(&mut wb, &config.axes); if let Some(path) = output.or(model_file) { @@ -318,30 +380,57 @@ fn run_headless_import( } fn run_wizard_import( - import_value: Value, + pipeline: ImportPipeline, _config: &ImportConfig, model_file: Option, ) -> Result<()> { let workbook = get_initial_workbook(&model_file)?; // The wizard configures itself inside the TUI. // TODO: pass `_config` to it for pre-population. - run_tui(workbook, model_file, Some(import_value)) + run_tui(workbook, model_file, Some(pipeline)) } // ── Import data loading ────────────────────────────────────────────────────── -fn get_import_data(paths: &[PathBuf]) -> Option { +fn get_import_data(args: &[PathBuf]) -> Option { + use improvise::import::csv_parser::{ImportPath, expand_import_paths}; + // Whether an argument is a folder is the one filesystem question asked here. + let classified: Vec = args + .iter() + .map(|p| match p.is_dir() { + true => ImportPath::Folder(p.clone()), + false => ImportPath::File(p.clone()), + }) + .collect(); + let paths = match expand_import_paths(&improvise_io::storage::PhysicalFs, &classified) { + Ok(paths) => paths, + Err(e) => { + eprintln!("{e:#}"); + return None; + } + }; + let paths = paths.as_slice(); let all_csv = paths.iter().all(|p| csv_path_p(p)); + // A folder always merges, so even one CSV keeps its file identity (lpzu). + let a_folder = classified + .iter() + .any(|a| matches!(a, ImportPath::Folder(_))); - if paths.len() > 1 { + if paths.len() > 1 || a_folder { if !all_csv { eprintln!("Multi-file import only supports CSV files"); return None; } - match crate::import::csv_parser::merge_csvs(paths) { - Ok(records) => Some(Value::Array(records)), + let fs = improvise_io::storage::PhysicalFs; + match crate::import::csv_parser::merge_csvs_from(&fs, paths) { + Ok(merged) => { + for warning in &merged.warnings { + eprintln!("warning: {warning}"); + } + Some(ImportPipeline::from_merge(merged)) + } Err(e) => { - eprintln!("CSV merge error: {e}"); + eprintln!("CSV merge error: {e:#}"); None } } @@ -355,7 +444,7 @@ fn get_import_data(paths: &[PathBuf]) -> Option { Ok(content) => { if csv_path_p(path) { match crate::import::csv_parser::parse_csv(path) { - Ok(records) => Some(Value::Array(records)), + Ok(records) => Some(ImportPipeline::from_csv(records)), Err(e) => { eprintln!("CSV parse error: {e}"); None @@ -367,7 +456,7 @@ fn get_import_data(paths: &[PathBuf]) -> Option { eprintln!("JSON parse error: {e}"); None } - Ok(json) => Some(json), + Ok(json) => Some(ImportPipeline::new(json)), } } } @@ -396,12 +485,16 @@ fn run_headless_commands(cmds: &[String], file: &Option) -> Result<()> for line in cmds { // The whole line is syntax-checked before any of it runs, exactly as // before; only CONSTRUCTION moved — `context/code/cli.md`. - if let Err(e) = command::parse_line(line) { - eprintln!("Parse error: {e}"); - exit_code = 1; - continue; - } - for segment in command::parse::segments(line) { + let segments = command::parse_line(line).and_then(|_| command::parse::segments(line)); + let segments = match segments { + Ok(segments) => segments, + Err(e) => { + eprintln!("Parse error: {e}"); + exit_code = 1; + continue; + } + }; + for segment in segments { let mut failed = None; let effects = { // Built against the cursor it is about to run against, so a diff --git a/src/ui/category_panel.rs b/src/ui/category_panel.rs index 6ee4b2e2..70530163 100644 --- a/src/ui/category_panel.rs +++ b/src/ui/category_panel.rs @@ -6,13 +6,14 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; +use crate::protocol::face::Face; use crate::protocol::{CatTreeEntry, CategoryTreeCache, flatten_cat_tree}; use crate::ui::app::Panel as PanelKind; use crate::ui::panel::PanelContent; use crate::view::Axis; -fn axis_display(axis: Axis) -> (String, Color) { - (axis.to_string(), crate::draw::axis_fg(axis.color())) +fn axis_display(axis: Axis) -> (String, Style) { + (axis.to_string(), crate::draw::face_style(Face::Axis(axis))) } /// The category panel draws one flattened tree and nothing else: the axis @@ -72,14 +73,15 @@ impl PanelContent for CategoryContent { match &self.tree[index] { CatTreeEntry::Category { - name, + display, item_count, expanded, axis, + .. } => { let indicator = if *expanded { "▼" } else { "▶" }; - let (axis_str, axis_color) = axis_display(*axis); - let name_part = format!("{indicator} {name} ({item_count})"); + let (axis_str, axis_style) = axis_display(*axis); + let name_part = format!("{indicator} {display} ({item_count})"); let axis_part = format!(" [{axis_str}]"); buf.set_string(inner.x, y, &name_part, base_style); @@ -88,11 +90,7 @@ impl PanelContent for CategoryContent { inner.x + name_part.width() as u16, y, &axis_part, - if is_selected { - base_style - } else { - Style::default().fg(axis_color) - }, + if is_selected { base_style } else { axis_style }, ); } } @@ -117,6 +115,7 @@ mod tests { let cache = CategoryTreeCache { categories: vec![CategoryRow { name: name.to_string(), + display: name.to_string(), axis: Axis::Row, items: vec!["x".to_string()], }], @@ -132,6 +131,29 @@ mod tests { buf.content()[31..59].iter().map(|c| c.symbol()).collect() } + #[test] + fn a_category_header_draws_its_display_not_its_key() { + let cache = CategoryTreeCache { + categories: vec![CategoryRow { + name: "Region".to_string(), + display: "Sales Area".to_string(), + axis: Axis::Row, + items: vec!["x".to_string()], + }], + }; + let area = Rect::new(0, 0, 30, 4); + let mut buf = Buffer::empty(area); + Panel::new( + CategoryContent::new(&cache, &Default::default()), + &AppMode::Normal, + 0, + ) + .render(area, &mut buf); + let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect(); + assert!(row.contains("Sales Area"), "{row}"); + assert!(!row.contains("Region"), "{row}"); + } + /// improvise-8l0c — `context/test-evidence/tui.md`. #[test] fn the_axis_tag_sits_beside_a_multibyte_category_name() { diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 106133d5..b4ccfbdc 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -860,6 +860,8 @@ mod tests { m.add_item_in_group("Region", "West", "Inland").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Apr", "Q2").unwrap(); + m.active_view_mut().set_expand_depth("Region", 1); + m.active_view_mut().set_expand_depth("Month", 1); m.add_item("Payer", "Alice").unwrap(); // Pinned so the byte-exact frames below characterize the LAYOUT and not // the view default, which grew two decimals with improvise-e0ex. diff --git a/src/ui/import_wizard_ui.rs b/src/ui/import_wizard_ui.rs index 87217533..88467f26 100644 --- a/src/ui/import_wizard_ui.rs +++ b/src/ui/import_wizard_ui.rs @@ -7,7 +7,8 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; -use crate::protocol::{WizardCache, WizardFieldKind, WizardStepKind}; +use crate::protocol::face::Face; +use crate::protocol::{WizardCache, WizardStepKind}; use crate::ui::text::wrap; /// The import wizard, drawn from a [`WizardCache`] (improvise-764) and from @@ -22,17 +23,6 @@ impl<'a> ImportWizardWidget<'a> { } } -/// Map a proposed field's kind to its accent, as `axis_fg` does for -/// `AxisColor` (gotcha 26). The decision is the protocol's; the color is not. -fn field_kind_fg(kind: WizardFieldKind) -> Color { - match kind { - WizardFieldKind::Category => Color::Green, - WizardFieldKind::Measure => Color::Cyan, - WizardFieldKind::TimeCategory => Color::Magenta, - WizardFieldKind::Label => Color::DarkGray, - } -} - impl<'a> Widget for ImportWizardWidget<'a> { fn render(self, area: Rect, buf: &mut Buffer) { let popup_w = area.width.min(80); @@ -139,7 +129,7 @@ impl<'a> Widget for ImportWizardWidget<'a> { } let is_sel = i == self.wizard.cursor; - let kind_color = field_kind_fg(proposal.kind); + let kind_style = crate::draw::face_style(Face::WizardField(proposal.kind)); let accept_str = if proposal.accepted { "[\u{2713}]" @@ -159,7 +149,7 @@ impl<'a> Widget for ImportWizardWidget<'a> { .bg(Color::Cyan) .add_modifier(Modifier::BOLD) } else if proposal.accepted { - Style::default().fg(kind_color) + kind_style } else { Style::default().fg(Color::DarkGray) }; @@ -190,7 +180,10 @@ impl<'a> Widget for ImportWizardWidget<'a> { if y >= inner.bottom().saturating_sub(2) { break; } - let header = format!(" {} (format: {})", proposal.field, proposal.format); + let header = format!( + " {} (format: {}) — {}", + proposal.field, proposal.format, proposal.shape + ); panel.put( x, y, @@ -231,7 +224,7 @@ impl<'a> Widget for ImportWizardWidget<'a> { x, hint_y, w, - "Space: toggle Enter: next \u{2190} back Esc: cancel", + "Space: toggle s: one category / split Enter: next \u{2190} back Esc: cancel", Style::default().fg(Color::DarkGray), ); } @@ -921,7 +914,7 @@ mod tests { .chain([0]) .map(|r| { let mut m = serde_json::Map::new(); - for i in 0..12 { + for i in 0..crate::model::MAX_CATEGORIES { m.insert(format!("c{i:02}"), json!(format!("v{r}"))); } m.insert("amount".into(), json!(1.0)); @@ -948,13 +941,13 @@ mod tests { buf_text(&buf) } - /// The reported import: twelve categories and a collision. + /// The reported import: a full set of categories and a collision. fn wizard_at_the_cap() -> ImportWizard { let recs: Vec = (0..3) .chain([0]) .map(|r| { let mut m = serde_json::Map::new(); - for i in 0..12 { + for i in 0..crate::model::MAX_CATEGORIES { m.insert(format!("c{i:02}"), json!(format!("v{r}"))); } m.insert("amount".into(), json!(1.0)); @@ -1188,7 +1181,7 @@ mod tests { "ConfigureDates\n", "┌ Import Wizard — Date Components ─────────────────────────────────────────────┐\n", "│Select date components to extract (Space toggle): │\n", - "│ When (format: %Y-%m-%d) │\n", + "│ When (format: %Y-%m-%d) — one category of the raw dates │\n", "│ [ ] Year │\n", "│ [ ] Month │\n", "│ [ ] Quarter │\n", @@ -1206,7 +1199,7 @@ mod tests { "│ │\n", "│ │\n", "│ │\n", - "│Space: toggle Enter: next ← back Esc: cancel │\n", + "│Space: toggle s: one category / split Enter: next ← back Esc: cancel │\n", "└──────────────────────────────────────────────────────────────────────────────┘\n", "\n", "====\n", diff --git a/src/ui/tile_bar.rs b/src/ui/tile_bar.rs index 60fac265..65d74e12 100644 --- a/src/ui/tile_bar.rs +++ b/src/ui/tile_bar.rs @@ -7,6 +7,7 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::protocol::TileBarCache; +use crate::protocol::face::Face; use crate::ui::app::AppMode; use crate::view::Axis; @@ -44,8 +45,11 @@ impl<'a> TileBar<'a> { tile_cat_idx, } } - fn axis_display(axis: Axis) -> (&'static str, Color) { - (axis.short_label(), crate::draw::axis_fg(axis.color())) + fn axis_display(axis: Axis) -> (&'static str, Style) { + ( + axis.short_label(), + crate::draw::face_style(Face::Axis(axis)), + ) } } @@ -75,7 +79,7 @@ impl<'a> Widget for TileBar<'a> { .iter() .map(|tile| { let (axis_symbol, _) = TileBar::axis_display(tile.axis); - format!(" [{} {axis_symbol}] ", tile.category) + format!(" [{} {axis_symbol}] ", tile.display) }) .collect(); let widths: Vec = labels.iter().map(|l| l.width() as u16).collect(); @@ -101,7 +105,7 @@ impl<'a> Widget for TileBar<'a> { break; } - let (_, axis_color) = TileBar::axis_display(tiles[i].axis); + let (_, axis_style) = TileBar::axis_display(tiles[i].axis); let is_selected = selected_cat_idx == Some(i); let style = if is_selected { Style::default() @@ -109,7 +113,7 @@ impl<'a> Widget for TileBar<'a> { .bg(Color::Cyan) .add_modifier(Modifier::BOLD) } else { - Style::default().fg(axis_color) + axis_style }; buf.set_string(x, area.y, &labels[i], style); @@ -186,6 +190,23 @@ mod tests { assert_eq!(was, now); } + #[test] + fn a_tile_draws_its_display_not_its_key() { + let mut app = app_with_two_categories(); + let wb = &mut app.model_state.workbook; + wb.model + .category_mut("Region") + .unwrap() + .set_display("Sales Area"); + let cache = project::tile_bar_of(&app); + let area = Rect::new(0, 0, 80, 1); + let mut buf = Buffer::empty(area); + TileBar::new(&cache, &AppMode::Normal, 0).render(area, &mut buf); + let text = buf_text(&buf); + assert!(text.contains("Sales Area"), "{text}"); + assert!(!text.contains("Region"), "{text}"); + } + /// Pins the rendered line. The equivalence test above says the *inputs* /// are unchanged by the migration; this says the drawing of them is. #[test] diff --git a/src/ui/view_panel.rs b/src/ui/view_panel.rs index 7083cf3d..c2081c49 100644 --- a/src/ui/view_panel.rs +++ b/src/ui/view_panel.rs @@ -62,7 +62,7 @@ impl PanelContent for ViewContent<'_> { }; let prefix = if is_active_view { "▶ " } else { " " }; - let name_text = format!("{prefix}{}", summary.name); + let name_text = format!("{prefix}{}", summary.display); let y = inner.y + index as u16; buf.set_string(inner.x, y, &name_text, style); @@ -98,6 +98,7 @@ mod tests { let cache = ViewListCache { views: vec![ViewSummary { name: view_name.to_string(), + display: view_name.to_string(), axes: vec![("Region".to_string(), Axis::Row)], }], }; @@ -119,6 +120,25 @@ mod tests { ); } + #[test] + fn a_view_row_draws_its_display_and_marks_the_active_view_by_its_key() { + use crate::protocol::ViewSummary; + use crate::ui::panel::Panel; + use ratatui::{buffer::Buffer, widgets::Widget}; + let cache = ViewListCache { + views: vec![ViewSummary { + name: "Default".to_string(), + display: "Overview".to_string(), + axes: vec![], + }], + }; + let area = Rect::new(0, 0, 30, 4); + let mut buf = Buffer::empty(area); + Panel::new(ViewContent::new(&cache, "Default"), &AppMode::Normal, 0).render(area, &mut buf); + let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect(); + assert!(row.contains("▶ Overview"), "{row}"); + } + fn app_with_two_views() -> App { let mut wb = Workbook::new("Test"); wb.add_category("Region").unwrap(); diff --git a/tests/bug_hunt_reproductions.rs b/tests/bug_hunt_reproductions.rs index c5ceb3b7..2475931d 100644 --- a/tests/bug_hunt_reproductions.rs +++ b/tests/bug_hunt_reproductions.rs @@ -12,6 +12,7 @@ fn grouped_item_order_survives_save() { let mut w = Workbook::new("test"); w.add_category("C").unwrap(); w.add_item_in_group("C", "first", "G").unwrap(); + w.active_view_mut().set_expand_depth("C", 1); w.add_item("C", "second").unwrap(); let r = parse_md(&format_md(&w)).unwrap(); assert_eq!( @@ -63,8 +64,8 @@ fn duplicate_csv_headers_not_silent() { 2, "both columns must survive as separate fields: {fields:?}" ); - assert_eq!(fields["A"].as_f64(), Some(1.0), "{fields:?}"); - assert_eq!(fields["A_2"].as_f64(), Some(2.0), "{fields:?}"); + assert_eq!(fields["A"], "1", "{fields:?}"); + assert_eq!(fields["A_2"], "2", "{fields:?}"); assert!( warnings .iter() @@ -77,7 +78,7 @@ fn duplicate_csv_headers_not_silent() { fn blank_measure_still_infers_numeric() { use improvise_io::import::{analyzer::FieldKind, wizard::ImportPipeline}; let (records, _) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEast,10\nWest,\n").unwrap(); - let p = ImportPipeline::new(serde_json::Value::Array(records)); + let p = ImportPipeline::from_csv(records); let proposal = p.proposals.iter().find(|p| p.field == "Revenue").unwrap(); assert_eq!(proposal.kind, FieldKind::Measure); } @@ -86,7 +87,7 @@ fn repeated_import_coordinates_not_silently_overwritten() { use improvise_io::import::wizard::ImportPipeline; let (records, _) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEast,10\nEast,20\n").unwrap(); - let mut pipeline = ImportPipeline::new(serde_json::Value::Array(records)); + let mut pipeline = ImportPipeline::from_csv(records); pipeline.on_duplicate = improvise_io::import::wizard::DuplicatePolicy::Sum; let (wb, warnings) = pipeline.build_model_with_warnings().unwrap(); let values: Vec = wb @@ -399,7 +400,7 @@ fn ordinary_controls() { let (records, warnings) = parse_csv_bytes_with_warnings(b"Region,Revenue\nEast,10\nWest,20\n").unwrap(); assert!(warnings.is_empty()); - let p = ImportPipeline::new(serde_json::Value::Array(records)); + let p = ImportPipeline::from_csv(records); assert_eq!( p.proposals .iter() @@ -429,8 +430,9 @@ fn slash_in_hidden_name_survives_roundtrip() { w.add_category("C").unwrap(); w.add_item("C", "A/B").unwrap(); w.add_item_in_group("C", "x", "G/H").unwrap(); + w.active_view_mut().set_expand_depth("C", 1); w.active_view_mut().hide_item("C", "A/B"); - w.active_view_mut().toggle_group_collapse("C", "G/H"); + w.active_view_mut().toggle_group_collapse("C", &["G/H"]); let text = format_md(&w); let r = parse_md(&text).unwrap_or_else(|e| panic!("unparseable: {e}\n{text}")); assert!( @@ -438,7 +440,7 @@ fn slash_in_hidden_name_survives_roundtrip() { "hidden lost:\n{text}" ); assert!( - r.active_view().is_group_collapsed("C", "G/H"), + r.active_view().is_group_collapsed("C", &["G/H"]), "collapsed lost:\n{text}" ); } diff --git a/tests/folder_import_views.rs b/tests/folder_import_views.rs new file mode 100644 index 00000000..0c26289b --- /dev/null +++ b/tests/folder_import_views.rs @@ -0,0 +1,65 @@ +//! A folder import's per-file views, saved and reloaded, as both front ends +//! project them (improvise-lpzu). Evidence: `context/test-evidence/cli.md`. + +use improvise::import::csv_parser::merge_csvs_from; +use improvise::import::wizard::ImportPipeline; +use improvise::persistence::{format_md, parse_md}; +use improvise::ui::app::App; +use improvise_command::ui::project::{Viewport, grid_in}; +use improvise_io::storage::{MemoryFs, Storage}; + +fn reloaded_folder_import() -> App { + let fs = MemoryFs::new(); + let files = [ + ( + "maat/age.csv", + "entity,age-months\nsrc/a.rs,2\nsrc/c.rs,7\n", + ), + ( + "maat/coupling.csv", + "entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\nsrc/b.rs,src/c.rs,40,3\n", + ), + ("maat/soc.csv", "entity,soc\n"), + ]; + for (path, body) in files { + fs.write(std::path::Path::new(path), body.as_bytes()) + .unwrap(); + } + let merged = merge_csvs_from(&fs, &files.map(|(p, _)| p)).unwrap(); + let (wb, _) = ImportPipeline::from_merge(merged) + .build_model_with_warnings() + .unwrap(); + App::new(parse_md(&format_md(&wb)).unwrap(), None) +} + +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_saved_per_file_view_projects_only_its_file() { + let app = reloaded_folder_import(); + let whole = Viewport { + row_offset: 0, + col_offset: 0, + rows: 100, + cols: 100, + }; + for (view, texts) in [("age", ["2.00", "7.00"]), ("coupling", ["5.00", "3.00"])] { + let grid = grid_in(&app, view, whole); + let file = grid.page_coords.iter().find(|p| p.cat == "File").unwrap(); + assert_eq!((file.selected.as_str(), &file.items), (view, &None)); + let drawn: Vec<&str> = grid + .cells + .iter() + .flatten() + .map(|c| c.text.as_str()) + .filter(|t| !t.is_empty()) + .collect(); + assert_eq!(drawn, texts, "{view}"); + } + + // A header-only file: its item exists, and its view has nothing editable. + let soc = grid_in(&app, "soc", whole); + let file = soc.page_coords.iter().find(|p| p.cat == "File").unwrap(); + assert_eq!(file.selected, "soc"); + assert!(soc.records, "a file with no measure opens in records mode"); + assert_eq!(soc.extent.rows, 0, "{soc:?}"); +} diff --git a/tests/grid_frame_agreement.rs b/tests/grid_frame_agreement.rs index c7d66655..12364556 100644 --- a/tests/grid_frame_agreement.rs +++ b/tests/grid_frame_agreement.rs @@ -4,7 +4,7 @@ use improvise::model::cell::{CellKey, CellValue}; use improvise::ui::app::{App, AppMode, ViewCursor}; use improvise::ui::grid::GridWidget; -use improvise::view::{Axis, GridLayout}; +use improvise::view::{Axis, GridLayout, GroupFold}; use improvise::workbook::Workbook; use improvise_command::ui::project; use improvise_protocol::{FrameEntry, GridCache, Snapshot, ViewState}; @@ -31,10 +31,12 @@ fn two_level_rows() -> Workbook { m.add_item("Type", "Clothing").unwrap(); m.add_item_in_group("Month", "Jan", "Q1").unwrap(); m.add_item_in_group("Month", "Feb", "Q1").unwrap(); + m.active_view_mut().set_expand_depth("Month", 1); m.add_item("Month", "Sep").unwrap(); m.add_item("Recipient", "Carol").unwrap(); m.add_item_in_group("Recipient", "Alice", "Family").unwrap(); m.add_item_in_group("Recipient", "Bob", "Family").unwrap(); + m.active_view_mut().set_expand_depth("Recipient", 1); m.active_view_mut().set_axis("Recipient", Axis::Row); m.add_item("_Measure", "Revenue").unwrap(); m.add_item("_Measure", "Cost").unwrap(); @@ -188,10 +190,111 @@ fn browser_model(cache: &GridCache) -> RenderModel { #[test] fn the_two_front_ends_draw_the_same_row_header() { - let mut wb = two_level_rows(); - let (_app, cache) = whole_grid(&mut wb); + assert_row_headers_agree(&mut two_level_rows()); +} - let tui = tui_row_headers(&wb, &cache); +/// improvise-felo.6: Year > Quarter > Month in ONE category under a grouped one; +/// Y24 open two levels, Q2 and Y25 closed. Short names fit a TUI band cell. +fn a_date_hierarchy() -> Workbook { + let mut m = Workbook::new("Test"); + m.add_category("Recipient").unwrap(); + m.add_category("Date").unwrap(); + m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); + m.add_item("Recipient", "Carol").unwrap(); + for (month, quarter) in [("Jan", "Q1"), ("Feb", "Q1"), ("Apr", "Q2"), ("Jan25", "Q9")] { + m.add_item_in_group("Date", month, quarter).unwrap(); + } + m.add_item("Date", "Undated").unwrap(); + let date = m.model.category_mut("Date").unwrap(); + for (quarter, year) in [("Q1", "Y24"), ("Q2", "Y24"), ("Q9", "Y25")] { + date.add_group(improvise::model::category::Group::new(quarter).with_parent(year)); + } + m.add_item("_Measure", "Revenue").unwrap(); + let view = m.active_view_mut(); + view.set_axis("Recipient", Axis::Row); + view.set_axis("Date", Axis::Row); + view.set_axis("_Measure", Axis::Column); + view.set_expand_depth("Recipient", 1); + view.set_group_fold( + "Date", + "Y24", + GroupFold::Expanded(std::num::NonZeroUsize::new(2).unwrap()), + ); + view.set_group_fold("Date", "Q2", GroupFold::Collapsed); + for r in ["Alice", "Carol"] { + for d in ["Jan", "Feb", "Apr", "Jan25", "Undated"] { + m.model.set_cell( + coord(&[("Recipient", r), ("Date", d), ("_Measure", "Revenue")]), + CellValue::Number(1.0), + ); + } + } + m +} + +/// See `context/test-evidence/tui.md`. +#[test] +fn the_two_front_ends_draw_the_same_nested_hierarchy_on_rows() { + let mut wb = a_date_hierarchy(); + let (_app, cache) = whole_grid(&mut wb); + let groups: Vec = browser_rows(&cache) + .into_iter() + .filter_map(|r| match r { + RenderRow::Group { label, .. } => Some(label), + RenderRow::Data { .. } => None, + }) + .collect(); + assert_eq!( + groups, + ["Fam", "Y24", "Q1", "Q2", "Y25", "Y24", "Q1", "Q2", "Y25"], + "precondition: a year AND its quarters head their runs, under each recipient" + ); + assert_row_headers_agree(&mut wb); +} + +/// The column side of the same hierarchy: the band paints each column's +/// innermost group, coalesced by header, at both ends. +#[test] +fn the_two_front_ends_draw_the_same_nested_hierarchy_on_columns() { + let mut wb = a_date_hierarchy(); + wb.active_view_mut().transpose_axes(); + let (_app, cache) = whole_grid(&mut wb); + let model = browser_model(&cache); + let lines = tui_lines(&wb, &cache); + let rule = header_rule(&lines); + let levels = model.col_levels.len(); + let named: Vec<(Option<&str>, usize)> = model + .col_groups + .iter() + .map(|g| (g.label.as_deref(), g.span)) + .collect(); + assert!( + named.iter().any(|(l, _)| *l == Some("Q1")) && named.iter().any(|(l, _)| *l == Some("Y25")), + "precondition: a quarter run and a folded year run: {named:?}" + ); + let expected: Vec = model + .col_groups + .iter() + .flat_map(|g| { + let head = match &g.label { + Some(name) => format!("{} {name}", indicator(g.collapsed)), + None => String::new(), + }; + std::iter::once(head).chain(std::iter::repeat_n(String::new(), g.span - 1)) + }) + .collect(); + assert_eq!( + &tui_header_cells(&lines[rule - levels - 1], &cache), + &expected, + "the TUI's group line and the browser's runs disagree" + ); +} + +/// The row-header comparison, row by row: both ends, one cache. +fn assert_row_headers_agree(wb: &mut Workbook) { + let (_app, cache) = whole_grid(wb); + + let tui = tui_row_headers(wb, &cache); let browser = browser_rows(&cache); assert_eq!( @@ -376,6 +479,7 @@ fn leading_group_rows() -> Workbook { m.add_category("Recipient").unwrap(); m.add_item_in_group("Recipient", "Alice", "Fam").unwrap(); m.add_item_in_group("Recipient", "Bob", "Fam").unwrap(); + m.active_view_mut().set_expand_depth("Recipient", 1); m.add_item("Recipient", "Carol").unwrap(); m.add_item("_Measure", "Revenue").unwrap(); m.active_view_mut().set_axis("Recipient", Axis::Row); @@ -397,7 +501,7 @@ fn a_group_header_first_on_its_axis_reaches_both_front_ends() { let mut wb = leading_group_rows(); if collapse { wb.active_view_mut() - .toggle_group_collapse("Recipient", "Fam"); + .toggle_group_collapse("Recipient", &["Fam"]); } let (_app, cache) = whole_grid(&mut wb); @@ -455,7 +559,7 @@ fn the_collapse_state_crosses_as_a_fact_not_a_glyph() { let mut wb = two_level_rows(); if collapse { wb.active_view_mut() - .toggle_group_collapse("Recipient", "Family"); + .toggle_group_collapse("Recipient", &["Family"]); } let (_app, cache) = whole_grid(&mut wb); diff --git a/tests/headless_cli.rs b/tests/headless_cli.rs index 5af8a6c2..7b28007e 100644 --- a/tests/headless_cli.rs +++ b/tests/headless_cli.rs @@ -121,6 +121,58 @@ fn a_parse_error_still_exits_non_zero() { ); } +/// improvise-31oe.2 at the process boundary, with a control that exits 0. +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn an_unknown_category_axis_or_item_exits_non_zero_and_names_it() { + let scratch = Scratch::new("unknown_names"); + let doc = seeded(&scratch, "m.improv"); + let out = run_cmd(&["add-item Region East"], &doc); + assert!(out.status.success(), "the seed item: {}", stderr_of(&out)); + for (line, missing) in [ + ("set-axis Regoin row", "Regoin"), + ("set-page Region Nowhere", "Nowhere"), + ("set-axis Region sideways", "sideways"), + ] { + let out = run_cmd(&[line], &doc); + let said = stderr_of(&out); + assert!(!out.status.success(), "`{line}` exited 0; stderr={said:?}"); + assert!( + said.contains(missing), + "`{line}` did not name {missing}: {said:?}" + ); + } + let out = run_cmd(&["set-page Region East"], &doc); + assert!(out.status.success(), "the control: {}", stderr_of(&out)); +} + +/// improvise-31oe.3: one pipe-quoted argument, and a surplus one refused. +/// Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_pipe_quoted_name_reaches_the_document_whole() { + let scratch = Scratch::new("pipes"); + let doc = seeded(&scratch, "m.improv"); + let out = run_cmd(&["create-view |By area|"], &doc); + assert!(out.status.success(), "{}", stderr_of(&out)); + let text = std::fs::read_to_string(&doc).expect("the document"); + assert!(text.contains("## View: By area\n"), "{text}"); + assert!( + !text.contains("|By"), + "a pipe reached the view name: {text}" + ); + + let out = run_cmd(&["create-view By area"], &doc); + let said = stderr_of(&out); + assert!( + !out.status.success(), + "a surplus argument exited 0: {said:?}" + ); + assert!( + said.contains("\"area\""), + "the refusal does not name it: {said:?}" + ); +} + /// Diagnostics go to stderr so that stdout stays free for a data output, and /// so `2>/dev/null` is the whole suppression story. `context/code/cli.md`. #[test] @@ -612,3 +664,477 @@ fn a_headless_import_keeps_duplicates_separate_when_told_to() { assert!(doc.contains("= 10") && doc.contains("= 20"), "{doc}"); assert!(!doc.contains("= 30"), "{doc}"); } + +/// Dates across two years, one per month, so nothing collides. +fn dated_csv(scratch: &Scratch) -> PathBuf { + let csv = scratch.path("dated.csv"); + std::fs::write( + &csv, + "Date,Revenue\n2024-01-15,1\n2024-04-02,10\n2025-01-03,100\n", + ) + .unwrap(); + csv +} + +/// improvise-8jk6.3, headless: `--extract` builds ONE hierarchical category by +/// default, as the wizard does, and it is written with its `>` lines. +#[test] +fn a_headless_date_import_is_one_hierarchical_category() { + let scratch = Scratch::new("date_hierarchy"); + let out_path = scratch.path("out.improv"); + let out = run_import( + &dated_csv(&scratch), + &out_path, + &[ + "--extract", + "Date:Year", + "--extract", + "Date:Quarter", + "--extract", + "Date:Month", + ], + ); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!(!doc.contains("Date_Month"), "{doc}"); + assert!(doc.contains("- |2024-01|[|2024-Q1|]"), "{doc}"); + assert!(doc.contains("> |2024-Q1|[|2024|]"), "{doc}"); + assert!(!doc.contains("expanded"), "it opens folded: {doc}"); +} + +/// The flat split stays available headless, as `s` makes it in the wizard. +#[test] +fn a_headless_date_import_splits_when_told_to() { + let scratch = Scratch::new("date_split"); + let out_path = scratch.path("out.improv"); + let out = run_import( + &dated_csv(&scratch), + &out_path, + &["--extract", "Date:Month", "--split-date", "Date"], + ); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!(doc.contains("## Category: Date_Month"), "{doc}"); + assert!(doc.contains("2024-01-15"), "the raw dates stay: {doc}"); +} + +/// improvise-8jk6.3 review: a `--split-date` that names nothing splittable +/// said nothing and exited 0. +#[test] +fn split_date_naming_an_unknown_or_non_date_field_is_refused() { + let scratch = Scratch::new("split_date_unknown"); + for field in ["Nope", "Revenue"] { + let out_path = scratch.path("out.improv"); + let out = run_import( + &dated_csv(&scratch), + &out_path, + &["--extract", "Date:Month", "--split-date", field], + ); + assert!(!out.status.success(), "{field}: {}", stderr_of(&out)); + let err = stderr_of(&out); + assert!(err.contains("--split-date") && err.contains(field), "{err}"); + } +} + +/// improvise-8jk6.3 review: `--axis Date_Month:column` names a category the +/// hierarchical import no longer builds, and was dropped without a word. +#[test] +fn an_axis_naming_a_category_the_import_did_not_build_is_refused() { + let scratch = Scratch::new("axis_unknown"); + let out_path = scratch.path("out.improv"); + let out = run_import( + &dated_csv(&scratch), + &out_path, + &["--extract", "Date:Month", "--axis", "Date_Month:column"], + ); + assert!(!out.status.success(), "{}", stderr_of(&out)); + let err = stderr_of(&out); + assert!( + err.contains("Date_Month") && err.contains("--split-date"), + "{err}" + ); +} + +/// `--time` on a column with no date format makes it a time category without +/// dates, so there is nothing to split. +#[test] +fn split_date_on_a_time_field_with_no_date_format_is_refused() { + let scratch = Scratch::new("split_date_no_format"); + let out_path = scratch.path("out.improv"); + let out = run_import( + &dated_csv(&scratch), + &out_path, + &["--time", "Revenue", "--split-date", "Revenue"], + ); + assert!(!out.status.success(), "{}", stderr_of(&out)); + assert!( + stderr_of(&out).contains("--split-date"), + "{}", + stderr_of(&out) + ); +} + +/// A name that is no date part gets the categories that exist, never the date +/// hint (the README's old `--axis Measure:column` was this). +#[test] +fn an_absent_axis_category_that_is_no_date_part_names_what_was_built() { + let scratch = Scratch::new("axis_unknown_plain"); + let out_path = scratch.path("out.improv"); + let out = run_import( + &dated_csv(&scratch), + &out_path, + &["--axis", "Measure:column"], + ); + assert!(!out.status.success(), "{}", stderr_of(&out)); + let err = stderr_of(&out); + assert!( + err.contains("'Measure'") && err.contains("_Measure"), + "{err}" + ); + assert!(!err.contains("--split-date"), "no date hint: {err}"); +} + +/// The README's import recipe runs as printed. +#[test] +fn the_readmes_import_recipe_runs() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let readme = std::fs::read_to_string(root.join("README.md")).unwrap(); + let start = readme.find("improvise import examples/demo.csv").unwrap(); + let recipe = readme[start..] + .split("```") + .next() + .unwrap() + .replace("\\\n", " "); + let mut args: Vec = vec![]; + for (i, part) in recipe.split('"').enumerate() { + match i % 2 { + 1 => args.push(part.to_string()), + _ => args.extend(part.split_whitespace().map(String::from)), + } + } + let at = args.iter().position(|a| a == "-o").unwrap(); + args.truncate(at); + let scratch = Scratch::new("readme_recipe"); + let out = Command::new(bin()) + .arg("import") + .arg(root.join("examples/demo.csv")) + .args(args.iter().skip(3)) + .arg("-o") + .arg(scratch.path("out.improv")) + .output() + .unwrap(); + assert!(out.status.success(), "{}", stderr_of(&out)); +} + +/// code-maat's reports: three schemas, a stray note, and room for the output. +fn code_maat_folder(scratch: &Scratch) -> PathBuf { + let dir = scratch.path("maat"); + std::fs::create_dir_all(&dir).unwrap(); + for (name, body) in [ + ( + "coupling.csv", + "entity,coupled,degree,average-revs\nsrc/a.rs,src/b.rs,80,5\n", + ), + ("age.csv", "entity,age-months\nsrc/a.rs,2\nsrc/b.rs,9\n"), + ( + "abs-churn.csv", + "date,added,deleted,commits\n2026-01-02,10,3,2\n", + ), + ("README.txt", "not a report\n"), + ] { + std::fs::write(dir.join(name), body).unwrap(); + } + dir +} + +/// improvise-lpzu: a folder argument is its `*.csv` files, merged sparsely and +/// in name order. Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_headless_import_of_a_folder_merges_its_csv_files_and_repeats_byte_for_byte() { + let scratch = Scratch::new("folder_import"); + let dir = code_maat_folder(&scratch); + let out_path = dir.join("maat.improv"); + + let first = run_import(&dir, &out_path, &[]); + assert!(first.status.success(), "{}", stderr_of(&first)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + for line in [ + "File=coupling, _Measure=degree, coupled=|src/b.rs|, entity=|src/a.rs| = 80", + "File=age, _Measure=age-months, entity=|src/b.rs| = 9", + "File=abs-churn, _Measure=added, date=|2026-01-02| = 10", + ] { + assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}"); + } + assert!(!doc.contains("README"), "{doc}"); + let views: Vec<&str> = doc.lines().filter(|l| l.starts_with("## View: ")).collect(); + assert_eq!( + views, + [ + "## View: Default", + "## View: abs-churn", + "## View: age", + "## View: coupling" + ] + ); + assert!(doc.lines().any(|l| l == "File: filter, coupling"), "{doc}"); + assert!(doc.contains("Initial View: Default"), "{doc}"); + + // The output now sits in the folder; a second run must not read it. + let second = run_import(&dir, &out_path, &[]); + assert!(second.status.success(), "{}", stderr_of(&second)); + assert_eq!(std::fs::read_to_string(&out_path).unwrap(), doc); +} + +/// improvise-lpzu: a file and a folder together are one merge, and a folder +/// with nothing to import is named. Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_headless_import_takes_files_and_folders_together_and_refuses_an_empty_folder() { + let scratch = Scratch::new("folder_mixed"); + let dir = code_maat_folder(&scratch); + let extra = scratch.path("extra.csv"); + std::fs::write(&extra, "entity,age-months\nsrc/z.rs,4\n").unwrap(); + let out_path = scratch.path("out.improv"); + + let out = Command::new(bin()) + .args(["import", "--no-wizard", "-o"]) + .arg(&out_path) + .arg(&extra) + .arg(&dir) + .output() + .unwrap(); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!( + doc.contains("File=extra, _Measure=age-months, entity=|src/z.rs| = 4"), + "{doc}" + ); + assert!(doc.contains("File=coupling"), "{doc}"); + + let empty = scratch.path("empty"); + std::fs::create_dir_all(&empty).unwrap(); + let refused = run_import(&empty, &scratch.path("never.improv"), &[]); + assert!(!refused.status.success()); + let err = stderr_of(&refused); + assert!( + err.contains(&format!("no CSV files in folder {}", empty.display())), + "{err}" + ); + assert!(!scratch.path("never.improv").exists()); +} + +/// Runs `import --no-wizard -o out.improv` on a source it writes. +fn import_of(scratch: &Scratch, name: &str, body: &str, extra: &[&str]) -> (Output, PathBuf) { + let source = scratch.path(name); + std::fs::write(&source, body).unwrap(); + let out_path = scratch.path("out.improv"); + (run_import(&source, &out_path, extra), out_path) +} + +/// improvise-8jk6.4: a forced category's values are item names, so they keep +/// their source text. Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_csv_column_forced_to_category_keeps_its_text_verbatim() { + let scratch = Scratch::new("forced_category_text"); + let (out, out_path) = import_of( + &scratch, + "commits.csv", + "commit,lines\n00565796,3\n220944e2,5\n", + &["--category", "commit", "--measure", "lines"], + ); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!( + doc.lines().any(|l| l == "- |00565796|, |220944e2|"), + "the items are the source text:\n{doc}" + ); + for line in [ + "_Measure=lines, commit=|00565796| = 3", + "_Measure=lines, commit=|220944e2| = 5", + ] { + assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}"); + } +} + +/// improvise-8jk6.4: JSON has no source text for a number, so a forced +/// category names it as serde_json writes it. Evidence file. +#[test] +fn a_json_number_forced_to_category_is_named_as_json_writes_it() { + let scratch = Scratch::new("forced_category_json"); + let (out, out_path) = import_of( + &scratch, + "commits.json", + r#"[{"commit": 42, "n": 1}, {"commit": 1.5, "n": 2}, {"commit": 1e3, "n": 3}]"#, + &["--category", "commit", "--measure", "n"], + ); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!( + doc.lines().any(|l| l == "- |42|, |1.5|, |1000.0|"), + "the items are the numbers' JSON text:\n{doc}" + ); +} + +/// improvise-8jk6.5: a formula the user named and that does not parse is a +/// refusal, not a silent drop. Evidence: `context/test-evidence/cli.md`. +#[test] +fn an_import_formula_that_does_not_parse_refuses_names_it_and_writes_nothing() { + let scratch = Scratch::new("bad_formula"); + let (out, out_path) = import_of( + &scratch, + "f.csv", + "a,x\nk,1\n", + &[ + "--measure", + "x", + "--formula", + "Y = x +", + "--formula", + "Z = x", + "--formula", + "W = * 2", + ], + ); + let err = stderr_of(&out); + assert!(!out.status.success(), "a dropped formula exited 0: {err}"); + assert!(err.contains("'Y = x +'"), "names the formula: {err}"); + assert!(err.contains("'W = * 2'"), "names every bad one: {err}"); + assert!(err.contains("expected"), "carries the parse error: {err}"); + assert!(!err.contains("'Z = x'"), "names only the bad one: {err}"); + assert!(!out_path.exists(), "nothing is written"); +} + +/// improvise-8jk6.6: an import that kept no record says why, and exits +/// non-zero writing nothing. Evidence: `context/test-evidence/cli.md`. +#[test] +fn an_import_that_keeps_no_record_refuses_and_says_why() { + let scratch = Scratch::new("kept_none"); + let (out, out_path) = import_of(&scratch, "r.json", r#"[1, 2, "three"]"#, &[]); + let err = stderr_of(&out); + assert!(!out.status.success(), "{err}"); + assert!( + err.contains("all 3 records were skipped as not JSON objects"), + "says why: {err}" + ); + assert!(!out_path.exists(), "nothing is written"); +} + +/// improvise-8jk6.6: a partial skip warns with a count and exits 0, a choice +/// put to the user. Evidence: `context/test-evidence/cli.md`. +#[test] +fn an_import_that_skips_some_records_warns_with_a_count_and_still_writes() { + let scratch = Scratch::new("kept_some"); + let (out, out_path) = import_of( + &scratch, + "p.json", + r#"[{"a": "k", "x": 1}, 2, {"a": "m", "x": 4}]"#, + &[], + ); + let err = stderr_of(&out); + assert!(out.status.success(), "{err}"); + assert!( + err.contains("warning: Skipped 1 of 3 records that were not JSON objects"), + "{err}" + ); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!(doc.contains("_Measure=x, a=m = 4"), "{doc}"); +} + +/// A JSON array with one stray scalar among its records. +const ONE_STRAY: &str = r#"[{"a": "k", "x": 1}, 2, {"a": "m", "x": 4}]"#; + +/// improvise-8jk6.6, the user's ruling: `--strict` makes any skip a refusal +/// that writes nothing. Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_strict_import_that_skips_a_record_refuses_and_writes_nothing() { + let scratch = Scratch::new("strict_skip"); + let (out, out_path) = import_of(&scratch, "p.json", ONE_STRAY, &["--strict"]); + let err = stderr_of(&out); + assert!(!out.status.success(), "{err}"); + assert!( + err.contains("--strict: Skipped 1 of 3 records that were not JSON objects"), + "{err}" + ); + assert!(!out_path.exists(), "a strict refusal writes nothing"); +} + +/// The control: `--strict` refuses a skip, not every import. +#[test] +fn a_strict_import_that_skips_nothing_still_writes() { + let scratch = Scratch::new("strict_clean"); + let body = r#"[{"a": "k", "x": 1}, {"a": "m", "x": 4}]"#; + let (out, out_path) = import_of(&scratch, "p.json", body, &["--strict"]); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + assert!(doc.contains("_Measure=x, a=m = 4"), "{doc}"); +} + +/// improvise-8jk6.17 (F3): a CSV decimal measure is its number, not dropped +/// text. Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_csv_decimal_measure_imports_as_its_number() { + let scratch = Scratch::new("csv_decimal"); + let body = "Region,Revenue\nEast,1000.50\nWest,-2.25\n"; + let (out, out_path) = import_of(&scratch, "f.csv", body, &[]); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&out_path).unwrap(); + for line in [ + "Region=East, _Measure=Revenue = 1000.5", + "Region=West, _Measure=Revenue = -2.25", + ] { + assert!(doc.lines().any(|l| l == line), "{line} missing from\n{doc}"); + } +} + +/// improvise-8jk6.17 (F1): `import-json` reads a CSV as CSV, so its numeric +/// column is a measure. Evidence: `context/test-evidence/cli.md`. +#[test] +fn import_json_on_a_csv_reads_its_numeric_column_as_a_measure() { + let scratch = Scratch::new("import_json_csv"); + let csv = scratch.path("f.csv"); + std::fs::write(&csv, "Region,Revenue\nEast,1000.50\nWest,7\n").unwrap(); + let doc = scratch.path("m.improv"); + let out = run_cmd(&[&format!("import-json {}", csv.display())], &doc); + assert!(out.status.success(), "{}", stderr_of(&out)); + let text = std::fs::read_to_string(&doc).unwrap(); + assert!(!text.contains("## Category: Revenue"), "{text}"); + for line in [ + "Region=East, _Measure=Revenue = 1000.5", + "Region=West, _Measure=Revenue = 7", + ] { + assert!( + text.lines().any(|l| l == line), + "{line} missing from\n{text}" + ); + } +} + +/// improvise-lpzu, the user's ruling: a folder always merges, so even one CSV +/// keeps its file identity. Evidence: `context/test-evidence/cli.md`. +#[test] +fn a_folder_of_one_csv_still_gets_a_file_category_and_its_view_but_a_lone_file_does_not() { + let scratch = Scratch::new("folder_single"); + let dir = scratch.path("one"); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("age.csv"); + std::fs::write(&csv, "entity,age-months\nsrc/a.rs,2\n").unwrap(); + + let from_folder = scratch.path("folder.improv"); + let out = run_import(&dir, &from_folder, &[]); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&from_folder).unwrap(); + assert!(doc.contains("## Category: File\n- age\n"), "{doc}"); + assert!(doc.lines().any(|l| l == "File: filter, age"), "{doc}"); + assert!( + doc.contains("File=age, _Measure=age-months, entity=|src/a.rs| = 2"), + "{doc}" + ); + + let from_file = scratch.path("file.improv"); + let out = run_import(&csv, &from_file, &[]); + assert!(out.status.success(), "{}", stderr_of(&out)); + let doc = std::fs::read_to_string(&from_file).unwrap(); + assert!( + !doc.contains("File"), + "a lone file keeps today's shape: {doc}" + ); +} diff --git a/tests/records_wide_smoke.rs b/tests/records_wide_smoke.rs index c121b25a..38cfbbe3 100644 --- a/tests/records_wide_smoke.rs +++ b/tests/records_wide_smoke.rs @@ -18,7 +18,7 @@ fn imported_model_shows_one_row_per_record_with_measure_columns() { .expect("write csv"); let records = improvise::import::csv_parser::parse_csv(&csv).expect("csv parses"); - let pipeline = ImportPipeline::new(serde_json::Value::Array(records)); + let pipeline = ImportPipeline::from_csv(records); let workbook = pipeline.build_model().expect("model builds"); let saved = dir.path().join("w.improv"); diff --git a/web/app.js b/web/app.js index 5de93091..4236c026 100644 --- a/web/app.js +++ b/web/app.js @@ -155,9 +155,35 @@ function onFrame(text) { console.error("improvise: frame rejected", text, err); return; } + // Check for pending downloads (improvise-2mqz). The WASM client stores + // download bytes from DocumentBytes/CsvBytes frames; we trigger the download + // here via Blob + . + try { + const downloadJson = client.take_pending_download(); + if (downloadJson) { + const download = JSON.parse(downloadJson); + triggerDownload(download.filename, download.bytes, download.kind); + } + } catch (err) { + console.error("improvise: download failed", err); + } repaint(); } +/** Trigger a browser download from bytes (improvise-2mqz). */ +function triggerDownload(filename, bytes, kind) { + const mimeType = kind === "Document" ? "text/plain" : "text/csv"; + const blob = new Blob([new Uint8Array(bytes)], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + // Transports. /** The in-page worker: the standalone deployment, no server process. */ diff --git a/web/tests/bundle/wasm_surface.test.js b/web/tests/bundle/wasm_surface.test.js index a9f8550f..7f5e9f0a 100644 --- a/web/tests/bundle/wasm_surface.test.js +++ b/web/tests/bundle/wasm_surface.test.js @@ -91,6 +91,9 @@ test("the page still reaches the client through a handful of calls", () => { "on_key_event", "render_model", "set_viewport", + // improvise-2mqz: download frames stay in the Rust fold until the page + // takes the bytes and hands them to Blob + . + "take_pending_download", "typed_cell", ], );